diff --git a/CHANGELOG.md b/CHANGELOG.md index 152d56968..77828ea18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## [1.40.2] — 2026-10-05 + +A calmer Coach with conversations in a side panel and settings in place; the +top of the dashboard says what matters today; documents filter by procedure. + +### Added + +- **Coach panel.** Conversations in a collapsible right panel (a sheet on a + phone), grouped by day, with rename, delete with undo and a new-chat + button; a gear opens quick settings (provider, model and reasoning where + they are the user's, and the lookback). `/coach?settings=data` opens it. +- **Coach lookback as a real limit.** 7, 30 or 90 days, 12 months + (`lastYear`, new) or all; it limits the snapshot and every Coach tool, a + read beyond it answers `outside_reach`. MCP keeps its own windows. +- **Today overview.** The digest gains `lead`, `today[]`, `restMode` and + `score.steadyWeeks`/`steadyAtLeast`: a lead line with content (deterministic + without AI), up to five facts (medications, an appointment today or + tomorrow, last night, vitals, cycle, Rest Mode), the ring with how long the + score has been steady. The morning push line carries no vital values. +- **Procedure filter in the document archive (#1154).** `linkedProcedures` on + the usage endpoint; the list already accepted `encounterId`. + +### Changed + +- **Coach answers.** One action row (copy, read aloud, retry, time; tokens and + model); web thumbs and the evidence block removed; remember is an icon; + follow-up chips in the answer column; a scroll-to-bottom button; the + composer's "+" menu removed. +- **Settings → Coach** summarises what the Coach sees and links into the + overlay. +- Coach preferences parse field by field, so one invalid value no longer + drops the others. + +### Fixed + +- The medications fact counts the whole day on a 25-hour DST day. +- Providers without credentials are no longer offered in the Coach overlay; + an operator-managed provider shows as such. +- Document filter controls match the search field's height on a phone. + ## [1.40.1] — 2026-10-04 Restore opens older encrypted values again; faster pages on a long-used diff --git a/bundle-budget.json b/bundle-budget.json index cc062e9d9..125356595 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,5 +1,5 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged.", "routeBaselineKbGz": { "/page": 429, "/insights/page": 368, @@ -7,6 +7,6 @@ "/insights/mood/page": 379 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 4100, + "totalClientKbGz": 4155, "maxRechartsChunks": 1 } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 54c7d32f5..62082e6fa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.40.1 + version: 1.40.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -16410,7 +16410,8 @@ paths: required: false schema: type: string - description: Only documents linked to this visit. + description: Only documents linked to this visit. The procedure filter sends a `linkedProcedures` id from the usage + read; an id that is not one of the caller's visits matches nothing. - name: year in: query required: false @@ -16744,8 +16745,11 @@ paths: summary: Storage usage + effective limits description: "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` - (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes - carrying at least one live document link — the condition-filter chips). Read this before offering an upload." + (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, `linkedEpisodes` (episodes + carrying at least one live document link — the condition-filter chips), and `linkedProcedures` (visits of kind + PROCEDURE carrying at least one live document link, newest first — the procedure-filter choices; pass one's + `encounterId` to the list's `encounterId` filter). `linkedProcedures` is empty when a sharing grant does not + cover the visits section. Read this before offering an upload." responses: "200": description: Usage + limits. @@ -25929,7 +25933,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array @@ -32076,7 +32083,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array @@ -48822,7 +48832,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array @@ -68514,6 +68527,45 @@ components: - episodeId - name additionalProperties: false + linkedProcedures: + type: array + items: + type: object + properties: + encounterId: + type: string + occurredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + reason: + anyOf: + - type: string + - type: "null" + bodySite: + anyOf: + - type: string + - type: "null" + laterality: + anyOf: + - type: string + enum: + - LEFT + - RIGHT + - BOTH + - type: "null" + practitionerName: + anyOf: + - type: string + - type: "null" + required: + - encounterId + - occurredAt + - reason + - bodySite + - laterality + - practitionerName + additionalProperties: false assistAvailable: type: boolean description: "Whether a document read can run for this record now: a provider can serve it and the `documentAi` @@ -68539,6 +68591,7 @@ components: - maxFileBytes - acceptedExtensions - linkedEpisodes + - linkedProcedures - assistAvailable - contentIndex additionalProperties: false @@ -72427,6 +72480,20 @@ components: so this is what distinguishes a narrow score from a broad one. Optional so older cached digests without the field stay valid. $ref: "#/components/schemas/HealthScoreBasis" + steadyWeeks: + description: Whole weeks the score has held where it is (same band, within two points, computed the same way), read off + the stored daily scores. Null when it has not held for two weeks, when the stored record does not + reach today, or when the score on screen differs from the newest stored day in value, band, + algorithm version or composition. Shown in place of a delta, never beside one. + anyOf: + - type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + - type: "null" + steadyAtLeast: + description: "True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower + bound, said as \"at least\", and the run's start is unknown. Absent on older cached digests." + type: boolean required: - value - band @@ -72469,11 +72536,60 @@ components: anyOf: - type: string - type: "null" - description: First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable; - `line` then falls to its deterministic floor. + description: "The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that + only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence + qualifies; `line` then falls to its deterministic floor." + lead: + anyOf: + - type: object + properties: + text: + type: string + source: + type: string + enum: + - reaction + - briefing + - signal + description: "`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's + strongest signal." + required: + - text + - source + additionalProperties: false + - type: "null" + description: "The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities + are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its + personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the + deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40." + today: + maxItems: 5 + type: array + items: + $ref: "#/components/schemas/DailyTodayFact" + description: "The Today overview: up to 5 statements about the day in priority order (Rest Mode, medications taken + today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against + their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow + surface shows the first 4. Additive since v1.40." + restMode: + anyOf: + - type: object + properties: + day: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: 1-based day of the episode on the reader's calendar. + required: + - day + additionalProperties: false + - type: "null" + description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive + since v1.40." line: type: string - description: "Push / lock-screen line: cached-AI lead with a deterministic floor." + description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or + score), since it is shown on a locked phone; the in-app `lead` keeps its figures." worthALook: type: array items: @@ -72535,6 +72651,9 @@ components: - score - topSignal - briefingLead + - lead + - today + - restMode - line - worthALook - justIn @@ -72543,6 +72662,39 @@ components: additionalProperties: false description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget. + DailyTodayFact: + type: object + properties: + kind: + type: string + enum: + - rest_mode + - medications + - appointment + - sleep + - vitals + - cycle + description: Closed fact kind. + label: + type: string + description: Localised label (resolved server-side). + value: + type: string + description: Localised value (resolved server-side). + href: + type: string + description: The page the fact belongs to. + moduleKey: + description: The module that admitted the fact; absent for core data. + type: string + required: + - kind + - label + - value + - href + additionalProperties: false + description: One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is + the only place that does. DailyPriorityItem: type: object properties: @@ -77933,7 +78085,10 @@ components: - last7days - last30days - last90days + - lastYear - allTime + description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` + (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 4cfe63778..94f0c047d 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -78,6 +78,12 @@ roll back: dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against production unless you've taken a fresh `pg_dump` first. +4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account + that saved the Coach lookback "Last year" reads its whole Coach + preference blob as defaults on the older version, metric exclusions + included, until the setting is saved again. Ask affected accounts to + pick another lookback before the rollback, or re-save their exclusions + after it. ## Catalog templates diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 80374f8ed..abc0c487d 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -1047,23 +1047,24 @@ test.describe("axe-core authenticated route and state matrix", () => { ); }); - await test.step(`${theme} /coach open evidence disclosure and source chips`, async () => { + await test.step(`${theme} /coach open the areas an older answer looked at`, async () => { await page.goto(`/coach?c=${A11Y_CONVERSATION_ID}`, { waitUntil: "domcontentloaded", }); - const evidence = page.locator('[data-slot="coach-evidence"]').first(); - await expect(evidence).toBeVisible({ timeout: 15_000 }); - await evidence.locator("summary").click(); - await expect(evidence).toHaveAttribute("open", ""); + const steps = page.locator('[data-slot="coach-turn-steps"]').first(); + await expect(steps).toBeVisible({ timeout: 15_000 }); + const toggle = steps.locator('[data-slot="coach-turn-steps-toggle"]'); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); await expect( - evidence.locator('[data-slot="coach-source-chips"]'), + steps.locator('[data-slot="coach-turn-areas"]'), ).toBeVisible(); blocking.push( ...(await scanPaintedState( page, theme, - "/coach open evidence disclosure and source chips", - evidence, + "/coach open the areas an older answer looked at", + steps, )), ); }); diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts new file mode 100644 index 000000000..b4aec3b52 --- /dev/null +++ b/e2e/coach-answer-layout.spec.ts @@ -0,0 +1,336 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Locator, Page, Route, TestInfo } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +/** + * The geometry of a Coach answer, at 1440 x 900 (desktop project) and + * 390 x 844 (mobile project). + * + * Contracts under test: + * + * 1. Under an answer there is exactly one action row: the icon group and + * the time on the left, the tokens and model on the right, both halves + * on one line (their centres within 2 px, the row no taller than one + * 44 px target), the left group starting on the bubble's left edge and + * the meta ending inside the answer column. + * 2. The follow-up chips sit in the answer's column: their left edge is + * the bubble's, their text is the answer's size, and a chip is at most + * 36 px tall beside a pointer and at least 44 px on a phone. + * 3. Under a question, copy and remember share one row; nothing stacks + * below it. + * 4. Scrolled away from the end, a round button appears; it brings the + * thread back to the end and goes away again. + * 5. No thumbs, no evidence disclosure, no horizontal overflow. + * + * Screenshots are attached to the report (no pixel baselines) and, with + * `COACH_SHOT_DIR` set, written there too. + */ + +const CONVERSATION_ID = "coach-answer-layout-e2e"; +const TITLE = "Blood pressure and sleep"; + +const ANSWERS = [ + "Your blood pressure stayed in a steady band over the last month. The morning readings sat a little higher than the evening ones, which is common, and none of the weeks stands out from the others.", + "Sleep looked regular on most nights. The weekends ran about forty minutes later at both ends, so the total stayed much the same while the timing moved.", + "Your resting heart rate held steady across the same weeks. Nothing in it lines up with the later weekend nights, so the two seem to move independently for now.", + "The period before looked much the same for blood pressure. Two readings in the second week were higher than the rest, and both were taken shortly after a workout.", + "Taken together, the month reads calm: steady blood pressure, regular sleep and a resting heart rate without surprises. Measuring at the same time each morning keeps the trend easy to read.", +]; +const QUESTIONS = [ + "How was my blood pressure this month?", + "And my sleep?", + "What about my resting heart rate?", + "Compare blood pressure with the period before", + "Sum it up for me", +]; + +const STEPS = [ + { + id: "s1", + tool: "get_metric_series", + labelKey: "coach.step.readWindow", + label: "Checking: Blood pressure, last 30 days", + domain: "bp", + window: "last30days", + status: "done", + count: 28, + }, + { + id: "s2", + tool: "get_sleep", + labelKey: "coach.step.readWindow", + label: "Checking: Sleep, last 30 days", + domain: "sleep", + window: "last30days", + status: "done", + count: 30, + }, +]; + +const FOLLOW_UPS = [ + { + id: "f1", + kind: "previous_period", + labelKey: "coach.followUp.previousPeriod", + label: "Compare with the period before", + reuse: false, + origin: "server", + }, + { + id: "f2", + kind: "related_metric", + labelKey: "coach.followUp.relatedMetric", + label: "See also: Resting HR", + reuse: false, + origin: "server", + }, +]; + +function messages() { + // Today, a few minutes apart, so the time reads as a bare clock. + const base = Date.now() - 60 * 60 * 1000; + const at = (minute: number) => + new Date(base + minute * 60 * 1000).toISOString(); + return ANSWERS.flatMap((answer, index) => [ + { + id: `${CONVERSATION_ID}-u${index + 1}`, + role: "user", + content: QUESTIONS[index], + createdAt: at(index * 2), + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: `${CONVERSATION_ID}-a${index + 1}`, + role: "assistant", + content: answer, + createdAt: at(index * 2 + 1), + metricSource: { + windows: ["last30days"], + metrics: ["bp", "sleep"], + steps: STEPS, + ...(index === ANSWERS.length - 1 ? { followUps: FOLLOW_UPS } : {}), + }, + providerType: "mock", + promptVersion: "e2e", + tokensUsed: 1234, + model: "gpt-4o-mini-2024-07-18", + }, + ]); +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +async function mockConversation(page: Page) { + const summary = { + id: CONVERSATION_ID, + title: TITLE, + createdAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + updatedAt: new Date().toISOString(), + messageCount: ANSWERS.length * 2, + fenced: false, + attachments: [], + documentTitle: null, + }; + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const url = new URL(route.request().url()); + if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) { + return fulfilJson(route, { results: [] }); + } + if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) { + return fulfilJson(route, { + ...summary, + attachmentCount: 0, + summary: null, + messages: messages(), + }); + } + return fulfilJson(route, { conversations: [summary], nextCursor: null }); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +async function box(locator: Locator) { + const b = await locator.boundingBox(); + expect(b, "element has a box").not.toBeNull(); + return b!; +} + +async function shoot(page: Page, testInfo: TestInfo, name: string) { + const body = await page.screenshot({ fullPage: false }); + await testInfo.attach(name, { body, contentType: "image/png" }); + const dir = process.env.COACH_SHOT_DIR; + if (dir) { + mkdirSync(dir, { recursive: true }); + await page.screenshot({ path: join(dir, `${name}.png`) }); + } +} + +const VIEWPORTS = { + "chromium-desktop": { width: 1440, height: 900, label: "1440" }, + "chromium-mobile": { width: 390, height: 844, label: "390" }, +} as const; + +test.describe("Coach answer layout", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("one action row, chips in the answer column, jump to the latest", async ({ + page, + }, testInfo) => { + const viewport = VIEWPORTS[testInfo.project.name as keyof typeof VIEWPORTS]; + test.skip(!viewport, "measured in the desktop and the mobile project"); + const desktop = viewport.width >= 640; + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await serveAiBlock(page, aiBlockAvailable()); + await mockConversation(page); + + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + const thread = page.locator('[data-slot="coach-message-thread"]'); + const answers = page.locator('[data-slot="coach-bubble-assistant"]'); + await expect(answers).toHaveCount(ANSWERS.length, { timeout: 15_000 }); + const last = answers.last(); + await expect(last).toContainText(ANSWERS.at(-1)!.slice(0, 40)); + // The thread opens at its end. + await expect + .poll(() => + thread.evaluate( + (el) => el.scrollHeight - el.scrollTop - el.clientHeight, + ), + ) + .toBeLessThanOrEqual(2); + + if (desktop) await last.hover(); + await shoot(page, testInfo, `answer-${viewport.label}`); + + // 5. Nothing of the old furniture. + await expect( + page.locator('[data-slot^="coach-message-feedback"]'), + ).toHaveCount(0); + await expect(page.locator('[data-slot^="coach-evidence"]')).toHaveCount(0); + await expect(page.locator('[data-slot="coach-token-footer"]')).toHaveCount( + 0, + ); + for (const el of [page.locator("#main-content"), thread]) { + const overflow = await el.evaluate((n) => n.scrollWidth - n.clientWidth); + expect(overflow, "no horizontal overflow").toBeLessThanOrEqual(0); + } + + // 1. One action row under the answer. + const bubble = last.locator('[data-slot="coach-answer-bubble"]'); + const column = last.locator('[data-slot="coach-answer-column"]'); + const row = last.locator('[data-slot="coach-answer-actions"]'); + const left = row.locator('[data-slot="coach-answer-actions-left"]'); + const meta = row.locator('[data-slot="coach-answer-meta"]'); + await expect(row).toHaveCount(1); + await expect(meta).toContainText("1,234 tokens"); + await expect(row).toHaveCSS("opacity", "1"); + const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all( + [bubble, column, row, left, meta].map(box), + ); + expect( + Math.abs( + leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2), + ), + "left group and meta share one line", + ).toBeLessThanOrEqual(2); + expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44); + expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1); + expect(metaBox.x + metaBox.width).toBeLessThanOrEqual( + columnBox.x + columnBox.width + 1, + ); + await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount( + 1, + ); + + // 2. The chips in the answer's column, at the answer's size. + const chips = last.locator('[data-slot="coach-follow-up-chips"]'); + await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2); + const chipsBox = await box(chips); + expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1); + expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1); + const answerSize = await bubble.evaluate( + (el) => getComputedStyle(el).fontSize, + ); + for (const chip of await chips.locator("[data-follow-up-id]").all()) { + await expect(chip).toHaveCSS("font-size", answerSize); + const chipBox = await box(chip); + if (desktop) expect(chipBox.height).toBeLessThanOrEqual(36); + else expect(chipBox.height).toBeGreaterThanOrEqual(44); + } + + // 3. Copy and remember share the question's one row. + const question = page.locator('[data-slot="coach-bubble-user"]').last(); + if (desktop) await question.hover(); + const userRow = question.locator('[data-slot="coach-user-actions"]'); + const remember = userRow.locator('[data-slot="coach-remember-message"]'); + const copy = userRow.locator('[data-slot="coach-copy-message"]'); + await expect(remember).toHaveAttribute("aria-label", "Remember"); + const [rememberBox, copyBox] = await Promise.all([remember, copy].map(box)); + expect( + Math.abs( + rememberBox.y + + rememberBox.height / 2 - + (copyBox.y + copyBox.height / 2), + ), + ).toBeLessThanOrEqual(1); + // The question's column holds the bubble and its one row, nothing else. + expect( + await userRow.evaluate((el) => el.parentElement?.childElementCount), + ).toBe(2); + + // 4. Away from the end, the button appears and brings the thread back. + const jump = page.locator('[data-slot="coach-scroll-to-bottom"]'); + await expect(jump).toHaveAttribute("data-visible", "false"); + await expect(jump).toHaveCSS("opacity", "0"); + await thread.evaluate((el) => el.scrollTo({ top: 0, behavior: "auto" })); + await expect(jump).toHaveAttribute("data-visible", "true"); + await expect(jump).toHaveCSS("opacity", "1"); + await shoot(page, testInfo, `scrolled-up-${viewport.label}`); + const jumpBox = await box(jump); + const threadBox = await box(thread); + expect( + Math.abs( + jumpBox.x + jumpBox.width / 2 - (threadBox.x + threadBox.width / 2), + ), + "centred over the thread", + ).toBeLessThanOrEqual(1); + await jump.click(); + await expect + .poll(() => + thread.evaluate( + (el) => el.scrollHeight - el.scrollTop - el.clientHeight, + ), + ) + .toBeLessThanOrEqual(2); + await expect(jump).toHaveAttribute("data-visible", "false"); + await expect(jump).toHaveAttribute("tabindex", "-1"); + await expect(page.locator("#coach-composer-textarea")).toBeFocused(); + }); +}); diff --git a/e2e/coach-dialog.spec.ts b/e2e/coach-dialog.spec.ts index 513d778e5..a5dfc1478 100644 --- a/e2e/coach-dialog.spec.ts +++ b/e2e/coach-dialog.spec.ts @@ -550,10 +550,11 @@ test.describe("Coach dialog", () => { await expect(figure).toBeVisible(); await expect(table).toBeHidden(); - // The method line sits in the evidence disclosure. - await firstBubble.locator('[data-slot="coach-evidence-summary"]').click(); + // The method line closes the open steps list. await expect( - firstBubble.locator('[data-slot="coach-method-line"]'), + steps.locator( + '[data-slot="coach-turn-steps-panel"] [data-slot="coach-method-line"]', + ), ).toContainText(TURN_ONE_METHOD.text); // 3. A follow-up chip sends which chip it was. diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts new file mode 100644 index 000000000..b933afd30 --- /dev/null +++ b/e2e/coach-layout.spec.ts @@ -0,0 +1,642 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +/** + * The Coach page frame: the conversations panel on the right, its toggle in + * the top bar, the settings gear in the panel header, and New chat at the + * panel's bottom right. + * + * Contracts under test, at 1440, 1280 and 390 px: + * + * 1. From 1280 px the panel docks beside the thread, open on a first visit, + * and the reading column (thread and composer) is exactly as wide with + * the panel open as with it closed. The choice survives a reload. + * 2. Escape inside the docked panel closes it and returns focus to the + * toggle. + * 3. A row's menu renames by keyboard (Escape cancels without closing the + * panel, Enter saves once) and deletes with an Undo that keeps the + * DELETE from ever leaving; a delete left alone commits once. + * 4. The gear opens the quick settings as a popover; Escape closes it and + * focus returns to the gear. `/coach?settings=data` opens it on "What I + * can see" and drops the param from the URL. + * 5. axe finds nothing on the open panel or the open settings, in the + * light and the dark theme. + * 6. At 390 px the panel is a dialog sheet with New chat 16 px from the + * right edge; Escape closes it and focus returns to the toggle. The gear + * opens a bottom sheet that closes on Escape back to the gear. Nothing + * scrolls sideways. + * + * Every Coach route is stubbed; the provider chain and provider config are + * stubbed too, so the model section renders the same on every machine. + * Screenshots are attached to the report (no pixel baselines). + */ + +const NOW = Date.now(); +const HOUR = 60 * 60 * 1000; + +interface Conversation { + id: string; + title: string; + updatedAt: string; +} + +function conversations(): Conversation[] { + return [ + { + id: "frame-bp", + title: "Blood pressure this week", + updatedAt: new Date(NOW - HOUR).toISOString(), + }, + { + id: "frame-sleep", + title: "Sleep since May", + updatedAt: new Date(NOW - 26 * HOUR).toISOString(), + }, + { + id: "frame-weight", + title: "Weight over the summer", + updatedAt: new Date(NOW - 10 * 24 * HOUR).toISOString(), + }, + ]; +} + +const ANSWER = + "Your blood pressure held a steady band this week. The mornings ran a little higher than the evenings, which is common and nothing that stands out on its own."; + +function fulfilJson(route: Route, data: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +interface CoachMock { + patches: Array<{ id: string; title: string }>; + deletes: string[]; +} + +async function mockCoach(page: Page): Promise { + const state = conversations(); + const mock: CoachMock = { patches: [], deletes: [] }; + + const dto = (c: Conversation) => ({ + id: c.id, + title: c.title, + createdAt: c.updatedAt, + updatedAt: c.updatedAt, + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + }); + + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route: Route, request: Request) => { + const url = new URL(request.url()); + const match = url.pathname.match(/^\/api\/insights\/chat\/([^/]+)$/); + if (match) { + const id = decodeURIComponent(match[1]); + const found = state.find((c) => c.id === id); + if (request.method() === "PATCH") { + const { title } = request.postDataJSON() as { title: string }; + mock.patches.push({ id, title }); + if (found) found.title = title; + return fulfilJson(route, { id, title }); + } + if (request.method() === "DELETE") { + mock.deletes.push(id); + const idx = state.findIndex((c) => c.id === id); + if (idx >= 0) state.splice(idx, 1); + return fulfilJson(route, { deleted: true }); + } + if (!found) return fulfilJson(route, null, 404); + return fulfilJson(route, { + ...dto(found), + attachmentCount: 0, + summary: null, + messages: [ + { + id: `${id}-u`, + role: "user", + content: `Tell me about ${found.title.toLowerCase()}.`, + createdAt: found.updatedAt, + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: `${id}-a`, + role: "assistant", + content: ANSWER, + createdAt: found.updatedAt, + metricSource: null, + providerType: "mock", + promptVersion: "e2e", + tokensUsed: 640, + model: "mock", + }, + ], + }); + } + if (url.pathname.startsWith("/api/insights/chat/")) { + return fulfilJson(route, { results: [] }); + } + const q = (url.searchParams.get("q") ?? "").toLowerCase(); + return fulfilJson(route, { + conversations: state + .filter((c) => c.title.toLowerCase().includes(q)) + .map(dto), + nextCursor: null, + }); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); + await page.route("**/api/insights/coach/seeded-question*", (route) => + fulfilJson(route, { signal: null }), + ); + await page.route("**/api/insights/provider-chain", (route) => + route.request().method() === "GET" + ? fulfilJson(route, { + activeProvider: "local", + cachedActiveProvider: "local", + configuredChain: [ + { providerType: "local", enabled: true, available: true }, + { providerType: "admin-openai", enabled: true, available: true }, + ], + }) + : fulfilJson(route, { saved: true }), + ); + await page.route("**/api/user/ai-provider", (route) => + route.request().method() === "GET" + ? fulfilJson(route, { + provider: "LOCAL", + model: "qwen2.5", + baseUrl: "http://localhost:11434/v1", + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + localReasoningEffort: null, + compatReasoningEffort: null, + }) + : fulfilJson(route, { updated: true }), + ); + return mock; +} + +const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]'); +const panel = (page: Page) => + page.locator('[data-slot="coach-conversations-panel"]'); + +/** Width of the reading column: the thread's capped inner column. */ +async function columnWidths(page: Page) { + const thread = page.locator('[data-slot="coach-message-thread"] > *').first(); + const composer = page.locator('[data-slot="coach-page-composer"] > div'); + const t = await thread.boundingBox(); + const c = await composer.boundingBox(); + expect(t, "thread column").not.toBeNull(); + expect(c, "composer column").not.toBeNull(); + return { thread: t!.width, composer: c!.width, threadX: t!.x }; +} + +async function expectNoSidewaysScroll(page: Page) { + const overflow = await page.evaluate(() => { + const doc = document.scrollingElement!; + const main = document.getElementById("main-content")!; + return { + doc: doc.scrollWidth - doc.clientWidth, + main: main.scrollWidth - main.clientWidth, + }; + }); + expect(overflow.doc).toBeLessThanOrEqual(0); + expect(overflow.main).toBeLessThanOrEqual(0); +} + +async function shot(page: Page, testInfo: TestInfo, name: string) { + const path = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path }); + await testInfo.attach(name, { path, contentType: "image/png" }); + const dir = process.env.COACH_SHOTS_DIR; + if (dir) await page.screenshot({ path: `${dir}/${name}.png` }); +} + +/** + * Record every mount of the conversations sheet from the first byte on. + * Reads the added nodes rather than the live DOM, so a sheet that mounts and + * unmounts again before the observer's callback runs is still counted. + */ +async function watchSheetMounts(page: Page) { + await page.addInitScript(() => { + const w = window as unknown as { __coachSheetSeen?: boolean }; + w.__coachSheetSeen = false; + const sheet = '[role="dialog"][data-slot="coach-conversations-panel"]'; + new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node instanceof Element && + (node.matches(sheet) || node.querySelector(sheet)) + ) { + w.__coachSheetSeen = true; + } + } + } + }).observe(document, { childList: true, subtree: true }); + }); +} + +async function sheetMounted(page: Page): Promise { + return page.evaluate( + () => + (window as unknown as { __coachSheetSeen?: boolean }).__coachSheetSeen === + true, + ); +} + +async function openCoach(page: Page, path = "/coach?c=frame-bp") { + await page.goto(path, { waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER), + ).toBeVisible({ timeout: 15_000 }); +} + +async function waitForWidth(locator: Locator, width: number) { + await expect + .poll(async () => Math.round((await locator.boundingBox())?.width ?? -1)) + .toBe(width); +} + +test.describe("Coach page frame", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "each case sets its own viewport; the desktop project runs them all", + ); + await serveAiBlock(page, aiBlockAvailable()); + await page.emulateMedia({ reducedMotion: "reduce" }); + }); + + for (const width of [1440, 1280]) { + test(`${width}: the panel docks, toggles, persists, and the reading column never changes width`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await mockCoach(page); + await openCoach(page); + + // First visit: open. + await expect(panel(page)).toHaveAttribute("data-state", "open"); + await waitForWidth(panel(page), 288); + await expect(toggle(page)).toHaveAttribute("aria-expanded", "true"); + await expect(toggle(page)).toHaveAttribute( + "aria-controls", + "coach-conversations-panel", + ); + const open = await columnWidths(page); + expect(open.thread).toBeCloseTo(672, 0); + expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1); + // The panel sits right of the column, never over it. + const aside = (await panel(page).boundingBox())!; + expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread); + await expectNoSidewaysScroll(page); + await shot(page, testInfo, `coach-frame-${width}-open`); + + await toggle(page).click(); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + await waitForWidth(panel(page), 0); + await expect(toggle(page)).toHaveAttribute("aria-expanded", "false"); + const closed = await columnWidths(page); + expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1); + expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1); + // Closed, nothing inside the panel is reachable. + await expect( + panel(page).locator('[data-slot="coach-history-search"]'), + ).not.toBeInViewport(); + await expectNoSidewaysScroll(page); + await shot(page, testInfo, `coach-frame-${width}-closed`); + + // Remembered across a reload. + await openCoach(page); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + + // Open again, then Escape from inside closes it, focus to the toggle. + await toggle(page).click(); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + await panel(page).locator('[data-slot="coach-history-search"]').focus(); + await page.keyboard.press("Escape"); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + await expect(toggle(page)).toBeFocused(); + }); + } + + test("1440: picking a row opens it and New chat clears it, both in the URL", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockCoach(page); + await openCoach(page); + + const rows = panel(page).locator('[data-slot="coach-history-select"]'); + await expect(rows).toHaveCount(3); + await expect(panel(page).locator('[data-group="today"]')).toContainText( + "Blood pressure this week", + ); + await expect(panel(page).locator('[data-group="yesterday"]')).toContainText( + "Sleep since May", + ); + await expect(rows.nth(0)).toHaveAttribute("aria-current", "true"); + + await rows.nth(1).click(); + await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/); + await expect(rows.nth(1)).toHaveAttribute("aria-current", "true"); + + const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]'); + const fabBox = (await fab.boundingBox())!; + const asideBox = (await panel(page).boundingBox())!; + expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo( + 16, + 0, + ); + await fab.click(); + await expect(page).toHaveURL(/\/coach$/); + await expect( + page.locator('[data-slot="coach-input-textarea"]'), + ).toBeVisible(); + await expect(panel(page).locator('[aria-current="true"]')).toHaveCount(0); + }); + + test("1440: rename by keyboard and delete with undo from the row menu", async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 900 }); + const mock = await mockCoach(page); + await openCoach(page); + + const row = panel(page) + .locator('[data-slot="coach-history-item"]') + .filter({ hasText: "Sleep since May" }); + const menu = row.locator('[data-slot="coach-history-row-menu"]'); + await expect(menu).toHaveAttribute( + "aria-label", + "Actions for Sleep since May", + ); + + // Keyboard: focus the menu button, open it, pick Rename. + await menu.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("menuitem", { name: "Rename" }).click(); + const input = row.locator('[data-slot="coach-conversation-rename-input"]'); + await expect(input).toBeFocused(); + await input.fill("Discard this"); + await page.keyboard.press("Escape"); + await expect(input).toHaveCount(0); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + await expect(row).toContainText("Sleep since May"); + await expect(menu).toBeFocused(); + expect(mock.patches).toHaveLength(0); + + await page.keyboard.press("Enter"); + await page.getByRole("menuitem", { name: "Rename" }).click(); + await expect(input).toBeFocused(); + await input.fill("Sleep since spring"); + await page.keyboard.press("Enter"); + const renamed = panel(page) + .locator('[data-slot="coach-history-item"]') + .filter({ hasText: "Sleep since spring" }); + await expect(renamed).toHaveCount(1); + await expect(input).toHaveCount(0); + expect(mock.patches).toEqual([ + { id: "frame-sleep", title: "Sleep since spring" }, + ]); + + // Delete, then Undo: the row returns and no DELETE leaves. + await renamed.hover(); + await renamed.locator('[data-slot="coach-history-row-menu"]').click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); + await expect(renamed).toHaveCount(0); + await shot(page, testInfo, "coach-frame-1440-delete-toast"); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(renamed).toHaveCount(1); + await page.waitForTimeout(6_500); + expect(mock.deletes).toEqual([]); + + // Delete left alone commits once, after the undo window. + await renamed.hover(); + await renamed.locator('[data-slot="coach-history-row-menu"]').click(); + await page.getByRole("menuitem", { name: "Delete" }).click(); + await expect(renamed).toHaveCount(0); + await expect + .poll(() => mock.deletes, { timeout: 10_000 }) + .toEqual(["frame-sleep"]); + }); + + test("1440: the gear opens the quick settings as a popover; Escape returns to the gear", async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockCoach(page); + await openCoach(page); + + const gear = panel(page).locator('[data-slot="coach-settings"]'); + await gear.click(); + const popover = page.locator('[data-slot="coach-settings-popover"]'); + await expect(popover).toBeVisible(); + await expect(popover).toHaveAttribute("role", "dialog"); + await expect( + popover.locator('[data-slot="coach-quick-provider"]'), + ).toHaveValue("local"); + await expect( + popover.locator('[data-slot="coach-quick-model"]'), + ).toHaveValue("qwen2.5"); + await expect( + popover.locator('[data-slot="coach-sources-window"]'), + ).toBeVisible(); + // The popover stays inside the viewport. + const box = (await popover.boundingBox())!; + expect(box.x + box.width).toBeLessThanOrEqual(1440); + expect(box.y + box.height).toBeLessThanOrEqual(900); + await shot(page, testInfo, "coach-frame-1440-settings"); + + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + await expect(gear).toBeFocused(); + // Escape on the popover does not also close the panel. + await expect(panel(page)).toHaveAttribute("data-state", "open"); + }); + + test("1440: /coach?settings=data opens the settings on what the Coach sees", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockCoach(page); + // A closed panel is revealed for the deep link. + await page.addInitScript(() => { + window.localStorage.setItem("healthlog.coach.panelOpen", "false"); + }); + // The hydration render does not know the viewport yet; the phone sheet + // must not mount on a desktop even for a moment. + await watchSheetMounts(page); + await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" }); + + const popover = page.locator('[data-slot="coach-settings-popover"]'); + await expect(popover).toBeVisible({ timeout: 15_000 }); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + expect( + await sheetMounted(page), + "the phone sheet flashed on a desktop deep link", + ).toBe(false); + await expect( + popover.locator('[data-slot="coach-settings-data"]'), + ).toBeFocused(); + await expect(page).toHaveURL(/\/coach$/); + + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + }); + + for (const theme of ["light", "dark"] as const) { + test(`1440 ${theme}: axe finds nothing on the panel or the quick settings`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); + await mockCoach(page); + await openCoach(page); + await expect(page.locator("html")).toHaveClass( + new RegExp(`(^|\\s)${theme}(\\s|$)`), + ); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + + const scan = async (selector: string, label: string) => { + const result = await new AxeBuilder({ page }) + .include(selector) + .withTags([ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "best-practice", + ]) + .analyze(); + expect( + result.violations.map((v) => ({ + id: v.id, + nodes: v.nodes.map((n) => n.target.join(" ")), + })), + `${label} accessibility violations`, + ).toEqual([]); + }; + + await scan('[data-slot="coach-conversations-panel"]', "panel"); + await shot(page, testInfo, `coach-frame-1440-${theme}-panel`); + + await panel(page).locator('[data-slot="coach-settings"]').click(); + const popover = page.locator('[data-slot="coach-settings-popover"]'); + await expect( + popover.locator('[data-slot="coach-quick-provider"]'), + ).toBeVisible(); + await scan('[data-slot="coach-settings-popover"]', "settings"); + await shot(page, testInfo, `coach-frame-1440-${theme}-settings`); + }); + } + + test("390: /coach?settings=data opens the sheet and the settings on what the Coach sees", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockCoach(page); + await watchSheetMounts(page); + await page.goto("/coach?settings=data", { waitUntil: "domcontentloaded" }); + + // The settings bottom sheet opens over the conversations sheet and + // hides it from the accessibility tree, so the sheet is found by slot. + await expect(page.locator('[data-slot="coach-settings-data"]')).toBeVisible( + { timeout: 15_000 }, + ); + await expect( + page.locator('[role="dialog"][data-slot="coach-conversations-panel"]'), + ).toBeVisible(); + // The positive control for the desktop case: the same watcher sees the + // sheet mount where it belongs. + expect(await sheetMounted(page)).toBe(true); + await expect(page).toHaveURL(/\/coach$/); + }); + + test("390: the panel is a sheet, the gear a bottom sheet, and nothing scrolls sideways", async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockCoach(page); + await openCoach(page); + + // Below 1280 px the panel waits to be asked. + await expect(panel(page)).toHaveCount(0); + await expect(toggle(page)).toHaveAttribute("aria-expanded", "false"); + await expectNoSidewaysScroll(page); + await shot(page, testInfo, "coach-frame-390-thread"); + + await toggle(page).click(); + const sheet = page.getByRole("dialog", { name: "Conversations" }); + await expect(sheet).toBeVisible(); + await expect(toggle(page)).toHaveAttribute("aria-expanded", "true"); + const fab = sheet.locator('[data-slot="coach-panel-new-chat"]'); + const fabBox = (await fab.boundingBox())!; + expect(390 - (fabBox.x + fabBox.width)).toBeCloseTo(16, 0); + expect(fabBox.width).toBeGreaterThanOrEqual(44); + await shot(page, testInfo, "coach-frame-390-panel"); + + await page.keyboard.press("Escape"); + await expect(sheet).toHaveCount(0); + await expect(toggle(page)).toBeFocused(); + + // The gear in the sheet opens a bottom sheet over it. + await toggle(page).click(); + await expect(sheet).toBeVisible(); + const gear = sheet.locator('[data-slot="coach-settings"]'); + await gear.click(); + const settings = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(settings).toHaveAttribute("data-variant", "sheet"); + await expect( + settings.locator('[data-slot="coach-quick-provider"]'), + ).toBeVisible(); + const box = (await settings.boundingBox())!; + expect(Math.round(box.y + box.height)).toBe(844); + await shot(page, testInfo, "coach-frame-390-settings"); + await page.keyboard.press("Escape"); + await expect(settings).toHaveCount(0); + await expect(gear).toBeFocused(); + await expect(sheet).toBeVisible(); + + // Picking a conversation closes the sheet and opens the thread. + await sheet + .locator('[data-slot="coach-history-select"]') + .filter({ hasText: "Weight over the summer" }) + .click(); + await expect(sheet).toHaveCount(0); + await expect(page).toHaveURL(/\/coach\?c=frame-weight$/); + await expectNoSidewaysScroll(page); + }); +}); diff --git a/e2e/coach-results.spec.ts b/e2e/coach-results.spec.ts index a0f4d67af..ca211ec63 100644 --- a/e2e/coach-results.spec.ts +++ b/e2e/coach-results.spec.ts @@ -289,7 +289,7 @@ test.describe("Coach result tables and charts", () => { }); for (const theme of ["light", "dark"] as const) { - test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({ + test(`theme=${theme}: chart first, table on toggle, data used in the steps list`, async ({ page, }) => { await useTheme(page, theme); @@ -342,12 +342,18 @@ test.describe("Coach result tables and charts", () => { await table.locator('[data-slot="coach-result-view-chart"]').click(); await expect(figure).toBeVisible(); - // The table the answer only used waits in the evidence disclosure. + // The table the answer only used waits in the open steps list. await expect( bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'), ).toBeHidden(); - await bubble.locator('[data-slot="coach-evidence-summary"]').click(); - const dataUsed = bubble.locator('[data-slot="coach-data-used"]'); + const stepsToggle = bubble.locator( + '[data-slot="coach-turn-steps-toggle"]', + ); + await expect(stepsToggle).toHaveAttribute("aria-expanded", "false"); + await stepsToggle.click(); + const dataUsed = bubble.locator( + '[data-slot="coach-turn-steps-panel"] [data-slot="coach-data-used"]', + ); await expect(dataUsed).toContainText("Data used (1)"); const workouts = dataUsed.locator( '[data-slot="coach-result-table"][data-ref="r2"]', diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index f76e12c9c..4b69093d9 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -115,6 +115,9 @@ test.describe("authenticated dashboard render", () => { score: { value: 82, band: "green", delta: 2 }, topSignal: null, briefingLead: "Your week is trending steady.", + lead: { text: "Your week is trending steady.", source: "briefing" }, + today: [], + restMode: null, line: "Your week is trending steady.", worthALook: [ { diff --git a/e2e/documents-procedure-filter.spec.ts b/e2e/documents-procedure-filter.spec.ts new file mode 100644 index 000000000..f3d8a0450 --- /dev/null +++ b/e2e/documents-procedure-filter.spec.ts @@ -0,0 +1,131 @@ +/** + * Document vault — the procedure filter. + * + * The facet beside the condition filter: it lists the procedures that hold a + * document, labelled the way the visits page heads them, narrows the list to + * one procedure's documents through the real `encounterId` filter, writes the + * choice to the URL (`?encounter=`), comes back from a deep link, and clears + * with the one clear control. Run at a 1440 px desktop and a 390 px phone, + * where the filter row must stay a single line with no sideways scroll. + * + * Fixture: `ensureProcedureFixture` (two procedures over a namespaced trio of + * documents), upserted before every test because the visits spec clears the + * account's visits; the dates sit far outside that spec's ±7-day window. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ensureProcedureFixture, + ensureVaultFixture, + PROCEDURE_DOC_PREFIX, + PROCEDURE_KNEE_ID, +} from "./setup/vault-fixture"; + +function card(page: Page, n: number) { + return page + .getByRole("button", { + name: `Open ${PROCEDURE_DOC_PREFIX} ${String(n).padStart(3, "0")}`, + }) + .first(); +} + +/** The filter row is one line and never scrolls sideways. */ +async function expectOneRow(page: Page) { + const bar = page.locator('[data-slot="document-filter-bar"]'); + const row = bar.locator("> div").first(); + const box = await row.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeLessThan(48); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); +} + +async function shot(page: Page, name: string, isMobile: boolean) { + const dir = process.env.E2E_SHOT_DIR; + if (!dir) return; + await page.screenshot({ + path: `${dir}/${name}-${isMobile ? "390" : "1440"}.png`, + animations: "disabled", + }); +} + +test.describe("document vault — procedure filter", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + await ensureVaultFixture(); + }); + + test.beforeEach(async ({ page, isMobile }) => { + await ensureProcedureFixture(); + await page.setViewportSize( + isMobile ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + }); + + test("picks a procedure, narrows to its documents, and clears", async ({ + page, + isMobile, + }) => { + await page.goto(`/documents?q=${PROCEDURE_DOC_PREFIX}`); + await expect(card(page, 3)).toBeVisible(); + await expect(card(page, 1)).toBeVisible(); + + const trigger = page.locator('[data-slot="document-procedure-filter"]'); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-label", "All procedures"); + await expectOneRow(page); + await shot(page, "procedure-filter-idle", isMobile); + + await trigger.click(); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await expect(menu).toContainText("Procedures and surgeries"); + // Headed like the visits page, with the body site and side beneath. + const knee = menu.getByRole("menuitemcheckbox", { + name: /Knee arthroscopy/, + }); + await expect(knee).toContainText("Knee · Left"); + await expect( + menu.getByRole("menuitemcheckbox", { name: /Appendectomy/ }), + ).toBeVisible(); + await shot(page, "procedure-filter-menu", isMobile); + await knee.click(); + + await expect(page).toHaveURL(new RegExp(`encounter=${PROCEDURE_KNEE_ID}`)); + await expect(card(page, 1)).toBeVisible(); + await expect(card(page, 2)).toBeVisible(); + await expect(card(page, 3)).toHaveCount(0); + await expect(trigger).toHaveAttribute("aria-label", "Knee arthroscopy"); + // Picked: the label shows at every width, phone included. + await expect(trigger).toContainText("Knee arthroscopy"); + await expectOneRow(page); + // The search field never collapses under the facets. + const search = await page + .getByRole("searchbox", { name: "Search documents" }) + .boundingBox(); + expect(search!.width).toBeGreaterThanOrEqual(48); + await shot(page, "procedure-filter-active", isMobile); + + await page.getByRole("button", { name: "Clear filters" }).click(); + await expect(page).not.toHaveURL(/encounter=/); + await expect(trigger).toHaveAttribute("aria-label", "All procedures"); + }); + + test("a deep link lands filtered and the facet names the procedure", async ({ + page, + }) => { + await page.goto( + `/documents?q=${PROCEDURE_DOC_PREFIX}&encounter=${PROCEDURE_KNEE_ID}`, + ); + await expect(card(page, 2)).toBeVisible(); + await expect(card(page, 3)).toHaveCount(0); + await expect( + page.locator('[data-slot="document-procedure-filter"]'), + ).toHaveAttribute("aria-label", "Knee arthroscopy"); + }); +}); diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts index 79d2e41a1..60ce0b522 100644 --- a/e2e/setup/vault-fixture.ts +++ b/e2e/setup/vault-fixture.ts @@ -550,6 +550,81 @@ export async function seedNamespaceDocs( } } +/** The procedure-filter fixture: two procedures, each with its own files. */ +export const PROCEDURE_KNEE_ID = "e2eprocknee0000000000001"; +export const PROCEDURE_APPENDIX_ID = "e2eprocappx0000000000001"; +export const PROCEDURE_DOC_PREFIX = "procfilter"; + +/** + * Seed the procedure-filter fixture: three namespaced documents and two + * procedures — a knee arthroscopy (reason + body site + side) holding two of + * them, and an appendectomy holding the third. Dated far from the MRT report + * so the visits spec's ±7-day window never sees them. Idempotent: the rows + * upsert by fixed id and come back live, so a `resetEncounters` from the visits + * spec between runs is repaired by the next call. + */ +export async function ensureProcedureFixture(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); + await seedNamespaceDocs(PROCEDURE_DOC_PREFIX, 3); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + const procedures = [ + { + id: PROCEDURE_KNEE_ID, + at: "2024-03-12T08:00:00Z", + reason: "Knee arthroscopy", + site: "Knee", + side: "LEFT", + docs: [0, 1], + }, + { + id: PROCEDURE_APPENDIX_ID, + at: "2023-06-20T08:00:00Z", + reason: "Appendectomy", + site: null, + side: null, + docs: [2], + }, + ]; + for (const p of procedures) { + await pool.query( + `INSERT INTO encounters + (id, user_id, occurred_at, status, kind, reason_encrypted, + body_site_encrypted, laterality, created_at, updated_at) + VALUES ($1, $2, $3, 'DONE', 'PROCEDURE', $4, $5, $6::laterality, NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + occurred_at = EXCLUDED.occurred_at, kind = 'PROCEDURE', + status = 'DONE', reason_encrypted = EXCLUDED.reason_encrypted, + body_site_encrypted = EXCLUDED.body_site_encrypted, + laterality = EXCLUDED.laterality, deleted_at = NULL, + updated_at = NOW()`, + [ + p.id, + userId, + p.at, + encryptStringToBytes(p.reason), + p.site ? encryptStringToBytes(p.site) : null, + p.side, + ], + ); + for (const n of p.docs) { + const docId = `e2e${PROCEDURE_DOC_PREFIX}${String(n).padStart(8, "0")}`; + await pool.query( + `INSERT INTO encounter_document_links + (id, encounter_id, document_id, user_id, created_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (encounter_id, document_id) DO NOTHING`, + [`${p.id.slice(0, 16)}lnk${n}`, p.id, docId, userId], + ); + } + } + } finally { + await pool.end(); + } +} + /** * v1.28 — seed the clinician-share document trio (see the `SHARE_*_DOC_ID` * constants). Enables the vault module for the e2e user and upserts one diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts new file mode 100644 index 000000000..9ac063b2d --- /dev/null +++ b/e2e/today-overview.spec.ts @@ -0,0 +1,319 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + mockDashboardSnapshot, + POPULATED_SUMMARIES, +} from "./utils/mock-dashboard-snapshot"; +import type { DailyDigest } from "@/lib/daily/digest"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; + +/** + * The Today overview on the dashboard hero, at a desktop and a phone width. + * + * What it proves is geometry, because that is what the unit suites cannot + * see: the facts sit beside the ring on a wide screen and above it on a + * phone, a phone shows four facts and a wide screen five, nothing pushes the + * page sideways, the hero keeps the width of the cards around it, and the + * ring keeps its size. Each state is also captured as an element screenshot + * under `test-results/today-overview/` for a visual check. + * + * The digests are what the server would publish for each state. They carry + * the pre-overview fields too (`briefingLead`, `line`), with the values the + * old server sent, so the same fixtures render the previous hero on an older + * build for a before/after comparison. + * + * Assertions anchor on `data-slot` / `data-kind`, never on visible text. + */ +test.use({ storageState: STORAGE_STATE_PATH }); + +const SHOTS = join(process.cwd(), "test-results", "today-overview"); + +const VIEWPORTS = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +] as const; + +const BASE: DailyDigest = { + generatedAt: new Date().toISOString(), + ai: DIGEST_AI_AVAILABLE, + phase: "final", + sleepPending: false, + score: { + value: 94, + band: "green", + delta: null, + deltaReason: "first_eligibility_window", + steadyWeeks: 4, + }, + topSignal: null, + briefingLead: null, + lead: null, + today: [], + restMode: null, + line: "Your health score today is 94.", + worthALook: [], + justIn: null, + reactionLine: null, +}; + +const FACTS: DailyDigest["today"] = [ + { + kind: "medications", + label: "Medications", + value: "1 of 3 taken", + href: "/medications", + moduleKey: "medications", + }, + { + kind: "appointment", + label: "Appointment", + value: "Tomorrow 09:30, Routine visit", + href: "/checkups", + }, + { + kind: "sleep", + label: "Last night", + value: "7h 20m, close to your usual", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitals", + value: "6 checked, all in your range", + href: "/insights", + }, + { + kind: "cycle", + label: "Cycle", + value: "Follicular, day 12", + href: "/cycle", + moduleKey: "cycle", + }, +]; + +const STATES: Record = { + // AI on. The old server led with the briefing's first sentence, which was + // the greeting. + "ai-on": { + ...BASE, + briefingLead: "Good morning.", + line: "Good morning.", + topSignal: { + sourceMetric: "bp", + tone: "good", + headline: "Blood pressure is sitting in the optimal band", + nudge: "", + delta: "↓ ~10 mmHg systolic vs the start of the window", + }, + lead: { + text: "Your latest blood pressure is sitting in the optimal band.", + source: "briefing", + }, + today: FACTS, + worthALook: [ + { + kind: "dose_window", + title: "Medication due", + body: "Ramipril is due soon.", + status: "info", + actions: [ + { + labelKey: "daily.action.logDose", + intent: "dose.log", + href: "/medications", + }, + ], + moduleKey: "medications", + }, + ], + }, + // AI off. The old server had only the score sentence, which the hero then + // removed again, leaving the ring and "Nothing needs your attention". + "ai-off": { + ...BASE, + ai: { + ...DIGEST_AI_AVAILABLE, + briefing: { + available: false, + reason: "no_provider", + onDeviceAllowed: false, + }, + }, + lead: { + text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.", + source: "signal", + }, + today: [FACTS[0], FACTS[2], FACTS[4]], + }, + // A quiet day: one calm sentence and no facts beyond it. + quiet: { + ...BASE, + lead: { + text: "All 6 of your latest vitals sit inside their usual range.", + source: "signal", + }, + }, +}; + +async function openDashboard(page: Page, digest: DailyDigest) { + await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES }); + await page.route(/\/api\/daily\/digest(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: digest, error: null }), + }), + ); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const hero = page.locator('[data-slot="today-hero"]'); + await expect(hero).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-slot="dashboard-tile-strip"]')).toBeVisible({ + timeout: 15_000, + }); + return hero; +} + +test.describe("Today overview geometry", () => { + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + }); + + for (const viewport of VIEWPORTS) { + for (const [state, digest] of Object.entries(STATES)) { + test(`${state} at ${viewport.width}px`, async ({ page }) => { + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-${state}.png`), + }); + + // Nothing pushes the page sideways. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + + // The hero spans the same column as the tile strip below it. + const heroBox = (await hero.boundingBox())!; + const stripBox = (await page + .locator('[data-slot="dashboard-tile-strip"]') + .boundingBox())!; + expect(Math.abs(heroBox.x - stripBox.x)).toBeLessThanOrEqual(1); + expect(Math.abs(heroBox.width - stripBox.width)).toBeLessThanOrEqual(1); + + // The ring keeps its size: the same 168 px dial as before. + const ring = hero.locator('[data-slot="score-ring"]').first(); + const ringBox = (await ring.boundingBox())!; + expect(Math.round(ringBox.width)).toBe(168); + expect(Math.round(ringBox.height)).toBe(168); + + const lead = hero.locator('[data-slot="today-hero-lead"]'); + await expect(lead).toBeVisible(); + await expect(lead).toHaveAttribute("data-source", digest.lead!.source); + const leadBox = (await lead.boundingBox())!; + + const facts = hero.locator('[data-slot="today-hero-fact"]'); + const expected = digest.today.length; + await expect(facts).toHaveCount(expected); + const visible = await facts.evaluateAll( + (items) => + items.filter((el) => (el as HTMLElement).offsetParent !== null) + .length, + ); + expect(visible).toBe( + viewport.width < 768 ? Math.min(expected, 4) : expected, + ); + + // The all-clear sentence never sits under a lead or facts. + await expect( + hero.locator('[data-slot="today-hero-all-clear"]'), + ).toHaveCount(0); + + if (expected === 0) { + await expect( + hero.locator('[data-slot="today-hero-today"]'), + ).toHaveCount(0); + // A quiet day keeps the full composition: lead beside the ring. + await expect(hero).toHaveAttribute("data-layout", "narrative"); + return; + } + + const block = hero.locator('[data-slot="today-hero-today"]'); + const blockBox = (await block.boundingBox())!; + // The facts follow the lead. + expect(blockBox.y).toBeGreaterThanOrEqual( + leadBox.y + leadBox.height - 1, + ); + if (viewport.width >= 768) { + // Beside the ring on a wide screen, never under it. + expect(blockBox.x + blockBox.width).toBeLessThanOrEqual( + ringBox.x + 1, + ); + expect(blockBox.y).toBeLessThan(ringBox.y + ringBox.height); + } else { + // Above the ring on a phone, so the day reads before the dial. + expect(blockBox.y + blockBox.height).toBeLessThanOrEqual( + ringBox.y + 1, + ); + } + + // No fact line overflows its own box. + const clipped = await facts.evaluateAll( + (items) => + items.filter((el) => { + const link = el.querySelector("a"); + return link ? link.scrollWidth > link.clientWidth + 1 : false; + }).length, + ); + expect(clipped).toBe(0); + }); + } + } + + test("the hero width does not move when the overview arrives", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + let calls = 0; + await mockDashboardSnapshot(page, { summaries: POPULATED_SUMMARIES }); + await page.route(/\/api\/daily\/digest(\?|$)/, async (route) => { + calls += 1; + // First paint: a quiet digest. Every later read carries the overview. + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: calls > 1 ? STATES["ai-on"] : STATES.quiet, + error: null, + }), + }); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const hero = page.locator('[data-slot="today-hero"]'); + await expect(hero).toBeVisible({ timeout: 15_000 }); + const before = (await hero.boundingBox())!; + + // The tab becomes visible again: the signal TanStack Query's focus + // manager refetches on, as `today-just-in.spec.ts` uses. + await page.evaluate(() => + window.dispatchEvent(new Event("visibilitychange")), + ); + await expect(hero.locator('[data-slot="today-hero-today"]')).toBeVisible({ + timeout: 15_000, + }); + const after = (await hero.boundingBox())!; + expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1); + expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1); + }); +}); diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts index ef9e150f9..78d6f3ad5 100644 --- a/e2e/v1341-ui-evidence.spec.ts +++ b/e2e/v1341-ui-evidence.spec.ts @@ -31,6 +31,9 @@ const NARRATIVE_DIGEST: DailyDigest = { }, topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null, briefingLead: LONG_HEADLINE_BRIEFING.paragraph, + lead: { text: LONG_HEADLINE_BRIEFING.paragraph, source: "briefing" }, + today: [], + restMode: null, line: LONG_HEADLINE_BRIEFING.paragraph, worthALook: [], justIn: null, @@ -104,6 +107,7 @@ test.describe("v1.34.1 deterministic UI evidence", () => { ...NARRATIVE_DIGEST, topSignal: null, briefingLead: null, + lead: null, line: "Dein Gesundheitsscore liegt heute bei 84.", reactionLine: null, }; diff --git a/messages/de.json b/messages/de.json index 4f69db510..94c4d89c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2877,7 +2877,6 @@ "sourcesFresh": "Aktuell", "sourcesStale": "Veraltet", "sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.", - "evidenceLabel": "Worauf bezieht sich das?", "metric": { "bp": "Blutdruck", "weight": "Gewicht", @@ -2923,14 +2922,6 @@ "skin_temp": "Hauttemperatur", "workouts": "Workouts" }, - "window": { - "last7days": "letzte 7 Tage", - "last30days": "letzte 30 Tage", - "last90days": "letzte 90 Tage", - "lastYear": "letzte 12 Monate", - "allTime": "gesamter Zeitraum (12 Monate im Detail)" - }, - "windowLabel": "Zeitraum", "settingsAriaLabel": "Coach-Einstellungen", "settingsTitle": "Coach-Einstellungen", "settingsDescription": "Stell ein, wie der Coach mit dir spricht.", @@ -2943,8 +2934,9 @@ "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Ausführlich", "settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.", - "settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife“).", - "settingsSourcesPointerLink": "Coach öffnen", + "settingsSourcesSummary": "Der Coach sieht {count} von {total} Datenbereichen und blickt {window} zurück.", + "settingsSourcesSummaryAll": "Der Coach sieht {count} von {total} Datenbereichen und deinen ganzen Verlauf.", + "settingsSourcesPointerLink": "Coach-Einstellungen öffnen", "settingsExcludeLabel": "Metriken ausschließen", "settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.", "settingsContextLabel": "Kontext mitschicken", @@ -2956,10 +2948,6 @@ "settingsSave": "Speichern", "settingsSaved": "Gespeichert", "settingsCancel": "Abbrechen", - "feedbackHelpful": "Hilfreich", - "feedbackUnhelpful": "Nicht ganz", - "feedbackThanks": "Danke für das Signal.", - "feedbackError": "Feedback konnte nicht gespeichert werden", "dailyLimitTitle": "Heute-Limit erreicht", "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", "providerRateLimitTitle": "Provider rate-limited", @@ -3009,7 +2997,23 @@ "settingsClustersLabel": "Daten, die der Coach sieht", "settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.", "settingsClusterNoData": "Noch keine Daten", - "sourcesMemberCount": "{count} Werte", + "sourcesMemberCountOne": "1 Wert", + "sourcesMemberCountFew": "{count} Werte", + "sourcesMemberCountOther": "{count} Werte", + "lookback": { + "label": "Wie weit der Coach zurückblickt", + "option": { + "last7days": "{days} Tage", + "last30days": "{days} Tage", + "last90days": "{days} Tage", + "lastYear": "{months} Monate", + "allTime": "Alles" + }, + "detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.", + "detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.", + "detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.", + "note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen. Antworten, die der Coach früher in älteren Unterhaltungen gegeben hat, können noch ältere Werte nennen." + }, "showConversations": "Unterhaltungen anzeigen", "hideConversations": "Unterhaltungen ausblenden", "heroGreeting": "Frage mich etwas zu deinen Daten", @@ -3113,7 +3117,34 @@ "errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.", "errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.", "errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.", - "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten." + "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.", + "frame": { + "showPanel": "Unterhaltungen einblenden", + "hidePanel": "Unterhaltungen ausblenden", + "panelDescription": "Deine Unterhaltungen mit dem Coach, neueste zuerst.", + "rowActions": "Aktionen für {title}", + "rename": "Umbenennen", + "delete": "Löschen", + "modelSection": "Modell", + "provider": "Antwortet mit", + "modelManaged": "Das Modell für diesen Anbieter legst du in den KI-Einstellungen fest.", + "noProvider": "Kein Anbieter verfügbar", + "saving": "Wird gespeichert…", + "saveFailed": "Die Änderung ließ sich nicht speichern.", + "allSettings": "Alle KI-Einstellungen", + "providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein.", + "operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)", + "operatorManaged": "Diese Verbindung verwaltet der Betreiber deines Servers. Sobald einer deiner eigenen Anbieter eingerichtet ist, antwortet er zuerst." + }, + "answer": { + "scrollToBottom": "Zur neuesten Nachricht", + "rememberDone": "Gemerkt", + "timeYesterday": "gestern {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 Bereich angesehen", + "areasDoneFew": "{count} Bereiche angesehen", + "areasDoneOther": "{count} Bereiche angesehen" + } }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -5090,11 +5121,6 @@ "brief": "Kurz", "default": "Standard", "detailed": "Ausführlich", - "defaultWindow": "Standardzeitraum", - "last7days": "Letzte 7 Tage", - "last30days": "Letzte 30 Tage", - "last90days": "Letzte 90 Tage", - "allTime": "Gesamter Zeitraum", "excludedMetrics": "Ausgeschlossene Messwerte", "dataClusters": "Datenbereiche", "customClusters": "Eigene Auswahl von Datenbereichen verwenden", @@ -10431,11 +10457,13 @@ "searchPlaceholder": "Titel oder Dateiname suchen…", "typeGroup": "Dokumenttyp", "conditionGroup": "Erkrankung", + "procedureGroup": "Eingriffe und Operationen", "yearGroup": "Jahr", "clear": "Filter zurücksetzen", "typeAll": "Alle Typen", "typeCount": "{count} Typen", "conditionAll": "Alle Erkrankungen", + "procedureAll": "Alle Eingriffe", "yearAll": "Alle Jahre" }, "card": { @@ -10896,8 +10924,8 @@ }, "daily": { "line": { - "score": "Dein Gesundheitsscore heute liegt bei {score}.", - "allClear": "Heute braucht nichts deine Aufmerksamkeit." + "allClear": "Heute braucht nichts deine Aufmerksamkeit.", + "scoreReady": "Dein Gesundheitsscore für heute ist da." }, "push": { "title": "Dein Morgenüberblick", @@ -10983,7 +11011,30 @@ "sleepPending": "Der Schlaf von letzter Nacht fehlt noch — der heutige Wert ist vorläufig.", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", - "ringLink": "{metric}-Details öffnen" + "ringLink": "{metric}-Details öffnen", + "heading": "Heute", + "steadyWeeksOne": "Seit {count} Woche stabil", + "steadyWeeksFew": "Seit {count} Wochen stabil", + "steadyWeeksOther": "Seit {count} Wochen stabil", + "valueWithUnit": "{value} {unit}", + "rangeWithUnit": "{low} bis {high} {unit}", + "vital": { + "RESTING_HEART_RATE": "Ruhepuls", + "HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität", + "RESPIRATORY_RATE": "Atemfrequenz", + "OXYGEN_SATURATION": "Sauerstoffsättigung", + "BODY_TEMPERATURE": "Körpertemperatur", + "SKIN_TEMPERATURE": "Hauttemperatur", + "BLOOD_PRESSURE_SYS": "Systolischer Blutdruck", + "BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck", + "BLOOD_GLUCOSE": "Blutzucker", + "PULSE": "Puls", + "WEIGHT": "Gewicht", + "generic": "Ein Vitalwert" + }, + "steadyAtLeastWeeksOne": "Seit mindestens {count} Woche stabil", + "steadyAtLeastWeeksFew": "Seit mindestens {count} Wochen stabil", + "steadyAtLeastWeeksOther": "Seit mindestens {count} Wochen stabil" }, "milestone": { "metric": { @@ -11019,6 +11070,68 @@ "body": "Ein ganzer Monat mit {metric} in deinem gewohnten Bereich. Wunderbar gleichmäßig." } } + }, + "lead": { + "vitalOutside": { + "above": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}.", + "below": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}." + }, + "vitalsOutside": { + "aboveOne": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.", + "aboveFew": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.", + "aboveOther": "{metric} liegt bei {value}, über deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.", + "belowOne": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weiterer Vitalwert liegt ebenfalls außerhalb.", + "belowFew": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb.", + "belowOther": "{metric} liegt bei {value}, unter deinem üblichen Bereich von {range}, und {count} weitere Vitalwerte liegen ebenfalls außerhalb." + }, + "sleep": { + "more": "Letzte Nacht hast du {duration} geschlafen, {diff} mehr als sonst.", + "less": "Letzte Nacht hast du {duration} geschlafen, {diff} weniger als sonst.", + "usual": "Letzte Nacht hast du {duration} geschlafen, etwa so viel wie sonst.", + "plain": "Letzte Nacht hast du {duration} geschlafen." + }, + "vitalsInRangeOne": "Dein letzter Vitalwert liegt in seinem üblichen Bereich.", + "vitalsInRangeFew": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.", + "vitalsInRangeOther": "Alle {count} letzten Vitalwerte liegen in ihrem üblichen Bereich.", + "vitalInRange": "{metric} liegt bei {value}, innerhalb deines üblichen Bereichs von {range}." + }, + "todayFact": { + "restMode": { + "label": "Ruhemodus", + "value": "Tag {day}" + }, + "medications": { + "label": "Medikamente", + "value": "{taken} von {scheduled} genommen" + }, + "appointment": { + "label": "Termin", + "today": "Heute {time}, {what}", + "tomorrow": "Morgen {time}, {what}" + }, + "sleep": { + "label": "Letzte Nacht", + "plain": "{duration}", + "usual": "{duration}, etwa wie sonst", + "more": "{duration}, {diff} mehr als sonst", + "less": "{duration}, {diff} weniger als sonst" + }, + "vitals": { + "label": "Vitalwerte", + "allInOne": "{count} geprüft, im Bereich", + "allInFew": "{count} geprüft, alle im Bereich", + "allInOther": "{count} geprüft, alle im Bereich", + "oneOut": { + "above": "{metric} über deinem Bereich", + "below": "{metric} unter deinem Bereich" + }, + "someOut": "{count} von {total} außerhalb deines Bereichs" + }, + "cycle": { + "label": "Zyklus", + "value": "{phase}, Tag {day}", + "dayOnly": "Tag {day}" + } } }, "reportSelection": { diff --git a/messages/en.json b/messages/en.json index 92d60007b..92aff03a8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2877,7 +2877,6 @@ "sourcesFresh": "Fresh", "sourcesStale": "Stale", "sourcesFooter": "The Coach reads only the data you've connected.", - "evidenceLabel": "What I'm looking at", "metric": { "bp": "Blood pressure", "weight": "Weight", @@ -2923,14 +2922,6 @@ "skin_temp": "Skin temperature", "workouts": "Workouts" }, - "window": { - "last7days": "last 7 days", - "last30days": "last 30 days", - "last90days": "last 90 days", - "lastYear": "last 12 months", - "allTime": "all time (12 months of detail)" - }, - "windowLabel": "Window", "settingsAriaLabel": "Coach settings", "settingsTitle": "Coach settings", "settingsDescription": "Tune how the Coach talks with you.", @@ -2943,8 +2934,9 @@ "settingsVerbosityDefault": "Default", "settingsVerbosityDetailed": "Detailed", "settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.", - "settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).", - "settingsSourcesPointerLink": "Open the coach", + "settingsSourcesSummary": "The Coach sees {count} of {total} data areas and looks back {window}.", + "settingsSourcesSummaryAll": "The Coach sees {count} of {total} data areas and your whole history.", + "settingsSourcesPointerLink": "Open Coach settings", "settingsExcludeLabel": "Exclude metrics", "settingsExcludeHint": "The Coach will not see data for selected metrics.", "settingsContextLabel": "Include context", @@ -2956,10 +2948,6 @@ "settingsSave": "Save", "settingsSaved": "Saved", "settingsCancel": "Cancel", - "feedbackHelpful": "Helpful", - "feedbackUnhelpful": "Not quite", - "feedbackThanks": "Thanks for the signal.", - "feedbackError": "Could not save feedback", "dailyLimitTitle": "Daily limit reached", "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.", "providerRateLimitTitle": "Provider rate-limited", @@ -3009,7 +2997,23 @@ "settingsClustersLabel": "Data the Coach sees", "settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.", "settingsClusterNoData": "No data yet", - "sourcesMemberCount": "{count} metrics", + "sourcesMemberCountOne": "1 metric", + "sourcesMemberCountFew": "{count} metrics", + "sourcesMemberCountOther": "{count} metrics", + "lookback": { + "label": "How far back the Coach can look", + "option": { + "last7days": "{days} days", + "last30days": "{days} days", + "last90days": "{days} days", + "lastYear": "{months} months", + "allTime": "All" + }, + "detailDays": "The Coach reads nothing older than {days} days, in any answer.", + "detailMonths": "The Coach reads nothing older than {months} months, in any answer.", + "detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, lab results from the last {labMonths} months.", + "note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out. Answers the Coach gave earlier in older conversations may still quote older values." + }, "showConversations": "Show conversations", "hideConversations": "Hide conversations", "heroGreeting": "Ask me anything about your data", @@ -3113,7 +3117,34 @@ "errorNotPermitted": "The Coach is not available inside a record someone shared with you.", "errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.", "errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.", - "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules." + "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.", + "frame": { + "showPanel": "Show conversations", + "hidePanel": "Hide conversations", + "panelDescription": "Your conversations with the Coach, newest first.", + "rowActions": "Actions for {title}", + "rename": "Rename", + "delete": "Delete", + "modelSection": "Model", + "provider": "Answers with", + "modelManaged": "The model for this provider is set in AI settings.", + "noProvider": "No provider available", + "saving": "Saving…", + "saveFailed": "That change could not be saved.", + "allSettings": "All AI settings", + "providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings.", + "operatorCodex": "ChatGPT (shared by the operator)", + "operatorManaged": "Your server's operator manages this connection. Once one of your own providers is set up, it answers first." + }, + "answer": { + "scrollToBottom": "Jump to the latest message", + "rememberDone": "Remembered", + "timeYesterday": "yesterday {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "Looked at 1 area", + "areasDoneFew": "Looked at {count} areas", + "areasDoneOther": "Looked at {count} areas" + } }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -5090,11 +5121,6 @@ "brief": "Brief", "default": "Default", "detailed": "Detailed", - "defaultWindow": "Default window", - "last7days": "Last 7 days", - "last30days": "Last 30 days", - "last90days": "Last 90 days", - "allTime": "All time", "excludedMetrics": "Excluded metrics", "dataClusters": "Data clusters", "customClusters": "Use a custom cluster selection", @@ -10431,11 +10457,13 @@ "searchPlaceholder": "Search title or filename…", "typeGroup": "Document type", "conditionGroup": "Condition", + "procedureGroup": "Procedures and surgeries", "yearGroup": "Year", "clear": "Clear filters", "typeAll": "All types", "typeCount": "{count} types", "conditionAll": "All conditions", + "procedureAll": "All procedures", "yearAll": "All years" }, "card": { @@ -10896,8 +10924,8 @@ }, "daily": { "line": { - "score": "Your health score today is {score}.", - "allClear": "Nothing needs your attention today." + "allClear": "Nothing needs your attention today.", + "scoreReady": "Your health score for today is ready." }, "push": { "title": "Your morning briefing", @@ -10983,7 +11011,30 @@ "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today.", - "ringLink": "Open {metric} details" + "ringLink": "Open {metric} details", + "heading": "Today", + "steadyWeeksOne": "Steady for {count} week", + "steadyWeeksFew": "Steady for {count} weeks", + "steadyWeeksOther": "Steady for {count} weeks", + "valueWithUnit": "{value} {unit}", + "rangeWithUnit": "{low} to {high} {unit}", + "vital": { + "RESTING_HEART_RATE": "Resting heart rate", + "HEART_RATE_VARIABILITY": "Heart-rate variability", + "RESPIRATORY_RATE": "Respiratory rate", + "OXYGEN_SATURATION": "Oxygen saturation", + "BODY_TEMPERATURE": "Body temperature", + "SKIN_TEMPERATURE": "Skin temperature", + "BLOOD_PRESSURE_SYS": "Systolic blood pressure", + "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", + "BLOOD_GLUCOSE": "Blood glucose", + "PULSE": "Pulse", + "WEIGHT": "Weight", + "generic": "A vital" + }, + "steadyAtLeastWeeksOne": "Steady for at least {count} week", + "steadyAtLeastWeeksFew": "Steady for at least {count} weeks", + "steadyAtLeastWeeksOther": "Steady for at least {count} weeks" }, "milestone": { "metric": { @@ -11019,6 +11070,68 @@ "body": "A full month with your {metric} in your usual range. Beautifully steady." } } + }, + "lead": { + "vitalOutside": { + "above": "{metric} is at {value}, above your usual range of {range}.", + "below": "{metric} is at {value}, below your usual range of {range}." + }, + "vitalsOutside": { + "aboveOne": "{metric} is at {value}, above your usual range of {range}, and {count} more vital is outside its range too.", + "aboveFew": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.", + "aboveOther": "{metric} is at {value}, above your usual range of {range}, and {count} more vitals are outside their range too.", + "belowOne": "{metric} is at {value}, below your usual range of {range}, and {count} more vital is outside its range too.", + "belowFew": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too.", + "belowOther": "{metric} is at {value}, below your usual range of {range}, and {count} more vitals are outside their range too." + }, + "sleep": { + "more": "Last night you slept {duration}, {diff} more than usual.", + "less": "Last night you slept {duration}, {diff} less than usual.", + "usual": "Last night you slept {duration}, close to your usual.", + "plain": "Last night you slept {duration}." + }, + "vitalsInRangeOne": "Your latest vital sits inside its usual range.", + "vitalsInRangeFew": "All {count} of your latest vitals sit inside their usual range.", + "vitalsInRangeOther": "All {count} of your latest vitals sit inside their usual range.", + "vitalInRange": "{metric} is at {value}, inside your usual range of {range}." + }, + "todayFact": { + "restMode": { + "label": "Rest mode", + "value": "Day {day}" + }, + "medications": { + "label": "Medications", + "value": "{taken} of {scheduled} taken" + }, + "appointment": { + "label": "Appointment", + "today": "Today {time}, {what}", + "tomorrow": "Tomorrow {time}, {what}" + }, + "sleep": { + "label": "Last night", + "plain": "{duration}", + "usual": "{duration}, close to your usual", + "more": "{duration}, {diff} more than usual", + "less": "{duration}, {diff} less than usual" + }, + "vitals": { + "label": "Vitals", + "allInOne": "{count} checked, in your range", + "allInFew": "{count} checked, all in your range", + "allInOther": "{count} checked, all in your range", + "oneOut": { + "above": "{metric} above your range", + "below": "{metric} below your range" + }, + "someOut": "{count} of {total} outside your range" + }, + "cycle": { + "label": "Cycle", + "value": "{phase}, day {day}", + "dayOnly": "Day {day}" + } } }, "reportSelection": { diff --git a/messages/es.json b/messages/es.json index 7fc4efbfb..9f21ada5b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2877,7 +2877,6 @@ "sourcesFresh": "Actuales", "sourcesStale": "Desactualizados", "sourcesFooter": "El coach solo lee los datos que has conectado.", - "evidenceLabel": "¿En qué se basa esto?", "metric": { "bp": "Presión arterial", "weight": "Peso", @@ -2923,14 +2922,6 @@ "skin_temp": "Temperatura de la piel", "workouts": "Entrenamientos" }, - "window": { - "last7days": "Últimos 7 días", - "last30days": "Últimos 30 días", - "last90days": "Últimos 90 días", - "lastYear": "últimos 12 meses", - "allTime": "todo el tiempo (12 meses en detalle)" - }, - "windowLabel": "Periodo", "settingsAriaLabel": "Ajustes del coach", "settingsTitle": "Ajustes del coach", "settingsDescription": "Configura cómo te habla el coach.", @@ -2943,8 +2934,9 @@ "settingsVerbosityDefault": "Estándar", "settingsVerbosityDetailed": "Detallado", "settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.", - "settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).", - "settingsSourcesPointerLink": "Abrir el coach", + "settingsSourcesSummary": "El coach ve {count} de {total} áreas de datos y mira {window} atrás.", + "settingsSourcesSummaryAll": "El coach ve {count} de {total} áreas de datos y todo el historial.", + "settingsSourcesPointerLink": "Abrir ajustes del coach", "settingsExcludeLabel": "Excluir métricas", "settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.", "settingsContextLabel": "Enviar contexto", @@ -2956,10 +2948,6 @@ "settingsSave": "Guardar", "settingsSaved": "Guardado", "settingsCancel": "Cancelar", - "feedbackHelpful": "Útil", - "feedbackUnhelpful": "Poco útil", - "feedbackThanks": "Gracias por la indicación.", - "feedbackError": "No se pudo guardar el comentario", "dailyLimitTitle": "Límite diario alcanzado", "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", "providerRateLimitTitle": "Proveedor con límite de tasa", @@ -3009,7 +2997,23 @@ "settingsClustersLabel": "Datos que ve el Coach", "settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.", "settingsClusterNoData": "Aún no hay datos", - "sourcesMemberCount": "{count} métricas", + "sourcesMemberCountOne": "1 métrica", + "sourcesMemberCountFew": "{count} métricas", + "sourcesMemberCountOther": "{count} métricas", + "lookback": { + "label": "Hasta dónde puede mirar atrás el Coach", + "option": { + "last7days": "{days} días", + "last30days": "{days} días", + "last90days": "{days} días", + "lastYear": "{months} meses", + "allTime": "Todo" + }, + "detailDays": "El Coach no lee nada con más de {days} días de antigüedad, en ninguna respuesta.", + "detailMonths": "El Coach no lee nada con más de {months} meses de antigüedad, en ninguna respuesta.", + "detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás, los análisis de los últimos {labMonths} meses.", + "note": "Tu perfil, tu medicación actual, una enfermedad en curso y las próximas citas siguen visibles; los resúmenes que necesitan más historial se acortan o se omiten. Las respuestas que el Coach dio antes en conversaciones anteriores pueden seguir citando valores más antiguos." + }, "showConversations": "Mostrar conversaciones", "hideConversations": "Ocultar conversaciones", "heroGreeting": "Pregúntame lo que quieras sobre tus datos", @@ -3113,7 +3117,34 @@ "errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.", "errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.", "errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.", - "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos." + "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.", + "frame": { + "showPanel": "Mostrar conversaciones", + "hidePanel": "Ocultar conversaciones", + "panelDescription": "Tus conversaciones con el coach, las más recientes primero.", + "rowActions": "Acciones para {title}", + "rename": "Renombrar", + "delete": "Eliminar", + "modelSection": "Modelo", + "provider": "Responde con", + "modelManaged": "El modelo de este proveedor se configura en los ajustes de IA.", + "noProvider": "Ningún proveedor disponible", + "saving": "Guardando…", + "saveFailed": "No se pudo guardar el cambio.", + "allSettings": "Todos los ajustes de IA", + "providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA.", + "operatorCodex": "ChatGPT (compartido por el operador)", + "operatorManaged": "El operador de tu servidor gestiona esta conexión. Cuando configures uno de tus propios proveedores, responderá primero." + }, + "answer": { + "scrollToBottom": "Ir al último mensaje", + "rememberDone": "Guardado", + "timeYesterday": "ayer {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 área consultada", + "areasDoneFew": "{count} áreas consultadas", + "areasDoneOther": "{count} áreas consultadas" + } }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -5090,11 +5121,6 @@ "brief": "Breve", "default": "Predeterminado", "detailed": "Detallado", - "defaultWindow": "Periodo predeterminado", - "last7days": "Últimos 7 días", - "last30days": "Últimos 30 días", - "last90days": "Últimos 90 días", - "allTime": "Todo el tiempo", "excludedMetrics": "Métricas excluidas", "dataClusters": "Grupos de datos", "customClusters": "Usar una selección personalizada de grupos", @@ -10431,11 +10457,13 @@ "searchPlaceholder": "Buscar por título o nombre de archivo…", "typeGroup": "Tipo de documento", "conditionGroup": "Afección", + "procedureGroup": "Procedimientos y cirugías", "yearGroup": "Año", "clear": "Borrar filtros", "typeAll": "Todos los tipos", "typeCount": "{count} tipos", "conditionAll": "Todas las afecciones", + "procedureAll": "Todos los procedimientos", "yearAll": "Todos los años" }, "card": { @@ -10896,8 +10924,8 @@ }, "daily": { "line": { - "score": "Tu puntuación de salud hoy es {score}.", - "allClear": "Hoy nada necesita tu atención." + "allClear": "Hoy nada necesita tu atención.", + "scoreReady": "Tu puntuación de salud de hoy está lista." }, "push": { "title": "Tu resumen matinal", @@ -10983,7 +11011,30 @@ "sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.", "worthALook": "Merece un vistazo", "allClear": "Hoy nada necesita tu atención.", - "ringLink": "Abrir detalles de {metric}" + "ringLink": "Abrir detalles de {metric}", + "heading": "Hoy", + "steadyWeeksOne": "Estable desde hace {count} semana", + "steadyWeeksFew": "Estable desde hace {count} semanas", + "steadyWeeksOther": "Estable desde hace {count} semanas", + "valueWithUnit": "{value} {unit}", + "rangeWithUnit": "{low} a {high} {unit}", + "vital": { + "RESTING_HEART_RATE": "Frecuencia cardíaca en reposo", + "HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca", + "RESPIRATORY_RATE": "Frecuencia respiratoria", + "OXYGEN_SATURATION": "Saturación de oxígeno", + "BODY_TEMPERATURE": "Temperatura corporal", + "SKIN_TEMPERATURE": "Temperatura de la piel", + "BLOOD_PRESSURE_SYS": "Presión arterial sistólica", + "BLOOD_PRESSURE_DIA": "Presión arterial diastólica", + "BLOOD_GLUCOSE": "Glucosa en sangre", + "PULSE": "Pulso", + "WEIGHT": "Peso", + "generic": "Un signo vital" + }, + "steadyAtLeastWeeksOne": "Estable desde hace al menos {count} semana", + "steadyAtLeastWeeksFew": "Estable desde hace al menos {count} semanas", + "steadyAtLeastWeeksOther": "Estable desde hace al menos {count} semanas" }, "milestone": { "metric": { @@ -11019,6 +11070,68 @@ "body": "Un mes entero con tu {metric} dentro de tu rango habitual. Maravillosamente estable." } } + }, + "lead": { + "vitalOutside": { + "above": "{metric} está en {value}, por encima de tu rango habitual de {range}.", + "below": "{metric} está en {value}, por debajo de tu rango habitual de {range}." + }, + "vitalsOutside": { + "aboveOne": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.", + "aboveFew": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.", + "aboveOther": "{metric} está en {value}, por encima de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.", + "belowOne": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signo vital más también está fuera de su rango.", + "belowFew": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango.", + "belowOther": "{metric} está en {value}, por debajo de tu rango habitual de {range}, y {count} signos vitales más también están fuera de su rango." + }, + "sleep": { + "more": "Anoche dormiste {duration}, {diff} más de lo habitual.", + "less": "Anoche dormiste {duration}, {diff} menos de lo habitual.", + "usual": "Anoche dormiste {duration}, cerca de lo habitual.", + "plain": "Anoche dormiste {duration}." + }, + "vitalsInRangeOne": "Tu último signo vital está dentro de su rango habitual.", + "vitalsInRangeFew": "Tus {count} últimos signos vitales están dentro de su rango habitual.", + "vitalsInRangeOther": "Tus {count} últimos signos vitales están dentro de su rango habitual.", + "vitalInRange": "{metric} está en {value}, dentro de tu rango habitual de {range}." + }, + "todayFact": { + "restMode": { + "label": "Modo descanso", + "value": "Día {day}" + }, + "medications": { + "label": "Medicamentos", + "value": "{taken} de {scheduled} tomados" + }, + "appointment": { + "label": "Cita", + "today": "Hoy {time}, {what}", + "tomorrow": "Mañana {time}, {what}" + }, + "sleep": { + "label": "Anoche", + "plain": "{duration}", + "usual": "{duration}, cerca de lo habitual", + "more": "{duration}, {diff} más de lo habitual", + "less": "{duration}, {diff} menos de lo habitual" + }, + "vitals": { + "label": "Signos vitales", + "allInOne": "{count} revisado, en tu rango", + "allInFew": "{count} revisados, todos en tu rango", + "allInOther": "{count} revisados, todos en tu rango", + "oneOut": { + "above": "{metric} por encima de tu rango", + "below": "{metric} por debajo de tu rango" + }, + "someOut": "{count} de {total} fuera de tu rango" + }, + "cycle": { + "label": "Ciclo", + "value": "{phase}, día {day}", + "dayOnly": "Día {day}" + } } }, "reportSelection": { diff --git a/messages/fr.json b/messages/fr.json index 989e0ab17..3bc9a18f4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2877,7 +2877,6 @@ "sourcesFresh": "À jour", "sourcesStale": "Obsolètes", "sourcesFooter": "Le coach ne lit que les données que tu as connectées.", - "evidenceLabel": "Sur quoi est-ce fondé ?", "metric": { "bp": "Tension artérielle", "weight": "Poids", @@ -2923,14 +2922,6 @@ "skin_temp": "Température cutanée", "workouts": "Entraînements" }, - "window": { - "last7days": "7 derniers jours", - "last30days": "30 derniers jours", - "last90days": "90 derniers jours", - "lastYear": "12 derniers mois", - "allTime": "toute la période (12 mois en détail)" - }, - "windowLabel": "Période", "settingsAriaLabel": "Paramètres du coach", "settingsTitle": "Paramètres du coach", "settingsDescription": "Configure comment le coach te parle.", @@ -2943,8 +2934,9 @@ "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Détaillé", "settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.", - "settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).", - "settingsSourcesPointerLink": "Ouvrir le coach", + "settingsSourcesSummary": "Le coach voit {count} domaines de données sur {total} et remonte sur {window}.", + "settingsSourcesSummaryAll": "Le coach voit {count} domaines de données sur {total} et tout l’historique.", + "settingsSourcesPointerLink": "Ouvrir les paramètres du coach", "settingsExcludeLabel": "Exclure des métriques", "settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.", "settingsContextLabel": "Envoyer le contexte", @@ -2956,10 +2948,6 @@ "settingsSave": "Enregistrer", "settingsSaved": "Enregistré", "settingsCancel": "Annuler", - "feedbackHelpful": "Utile", - "feedbackUnhelpful": "Peu utile", - "feedbackThanks": "Merci pour l’indication.", - "feedbackError": "Impossible d'enregistrer le retour", "dailyLimitTitle": "Limite quotidienne atteinte", "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", "providerRateLimitTitle": "Fournisseur limité en débit", @@ -3009,7 +2997,23 @@ "settingsClustersLabel": "Données vues par le Coach", "settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.", "settingsClusterNoData": "Pas encore de données", - "sourcesMemberCount": "{count} mesures", + "sourcesMemberCountOne": "1 mesure", + "sourcesMemberCountFew": "{count} mesures", + "sourcesMemberCountOther": "{count} mesures", + "lookback": { + "label": "Jusqu’où le Coach peut remonter", + "option": { + "last7days": "{days} jours", + "last30days": "{days} jours", + "last90days": "{days} jours", + "lastYear": "{months} mois", + "allTime": "Tout" + }, + "detailDays": "Le Coach ne lit rien de plus ancien que {days} jours, dans aucune réponse.", + "detailMonths": "Le Coach ne lit rien de plus ancien que {months} mois, dans aucune réponse.", + "detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, les analyses des {labMonths} derniers mois.", + "note": "Votre profil, vos médicaments actuels, une maladie en cours et vos prochains rendez-vous restent visibles ; les synthèses qui demandent plus d’historique sont raccourcies ou omises. Les réponses données plus tôt par le Coach dans d’anciennes conversations peuvent encore citer des valeurs plus anciennes." + }, "showConversations": "Afficher les conversations", "hideConversations": "Masquer les conversations", "heroGreeting": "Pose-moi une question sur tes données", @@ -3113,7 +3117,34 @@ "errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.", "errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.", "errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.", - "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules." + "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.", + "frame": { + "showPanel": "Afficher les conversations", + "hidePanel": "Masquer les conversations", + "panelDescription": "Tes conversations avec le coach, les plus récentes d’abord.", + "rowActions": "Actions pour {title}", + "rename": "Renommer", + "delete": "Supprimer", + "modelSection": "Modèle", + "provider": "Répond avec", + "modelManaged": "Le modèle de ce fournisseur se règle dans les paramètres IA.", + "noProvider": "Aucun fournisseur disponible", + "saving": "Enregistrement…", + "saveFailed": "Impossible d’enregistrer cette modification.", + "allSettings": "Tous les paramètres IA", + "providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA.", + "operatorCodex": "ChatGPT (partagé par l'opérateur)", + "operatorManaged": "L'opérateur de votre serveur gère cette connexion. Dès qu'un de vos propres fournisseurs est configuré, c'est lui qui répond en premier." + }, + "answer": { + "scrollToBottom": "Aller au dernier message", + "rememberDone": "Mémorisé", + "timeYesterday": "hier {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 domaine consulté", + "areasDoneFew": "{count} domaines consultés", + "areasDoneOther": "{count} domaines consultés" + } }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -5090,11 +5121,6 @@ "brief": "Bref", "default": "Par défaut", "detailed": "Détaillé", - "defaultWindow": "Période par défaut", - "last7days": "7 derniers jours", - "last30days": "30 derniers jours", - "last90days": "90 derniers jours", - "allTime": "Depuis toujours", "excludedMetrics": "Mesures exclues", "dataClusters": "Groupes de données", "customClusters": "Utiliser une sélection personnalisée de groupes", @@ -10431,11 +10457,13 @@ "searchPlaceholder": "Rechercher un titre ou un nom de fichier…", "typeGroup": "Type de document", "conditionGroup": "Affection", + "procedureGroup": "Interventions et chirurgies", "yearGroup": "Année", "clear": "Effacer les filtres", "typeAll": "Tous les types", "typeCount": "{count} types", "conditionAll": "Toutes les affections", + "procedureAll": "Toutes les interventions", "yearAll": "Toutes les années" }, "card": { @@ -10896,8 +10924,8 @@ }, "daily": { "line": { - "score": "Ton score de santé aujourd'hui est de {score}.", - "allClear": "Rien ne requiert ton attention aujourd'hui." + "allClear": "Rien ne requiert ton attention aujourd'hui.", + "scoreReady": "Ton score de santé du jour est prêt." }, "push": { "title": "Votre résumé du matin", @@ -10983,7 +11011,30 @@ "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui.", - "ringLink": "Ouvrir les détails de {metric}" + "ringLink": "Ouvrir les détails de {metric}", + "heading": "Aujourd'hui", + "steadyWeeksOne": "Stable depuis {count} semaine", + "steadyWeeksFew": "Stable depuis {count} semaines", + "steadyWeeksOther": "Stable depuis {count} semaines", + "valueWithUnit": "{value} {unit}", + "rangeWithUnit": "{low} à {high} {unit}", + "vital": { + "RESTING_HEART_RATE": "Fréquence cardiaque au repos", + "HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque", + "RESPIRATORY_RATE": "Fréquence respiratoire", + "OXYGEN_SATURATION": "Saturation en oxygène", + "BODY_TEMPERATURE": "Température corporelle", + "SKIN_TEMPERATURE": "Température cutanée", + "BLOOD_PRESSURE_SYS": "Tension systolique", + "BLOOD_PRESSURE_DIA": "Tension diastolique", + "BLOOD_GLUCOSE": "Glycémie", + "PULSE": "Pouls", + "WEIGHT": "Poids", + "generic": "Un signe vital" + }, + "steadyAtLeastWeeksOne": "Stable depuis au moins {count} semaine", + "steadyAtLeastWeeksFew": "Stable depuis au moins {count} semaines", + "steadyAtLeastWeeksOther": "Stable depuis au moins {count} semaines" }, "milestone": { "metric": { @@ -11019,6 +11070,68 @@ "body": "Un mois entier avec votre {metric} dans votre plage habituelle. Merveilleusement stable." } } + }, + "lead": { + "vitalOutside": { + "above": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}.", + "below": "{metric} est à {value}, en dessous de ta plage habituelle de {range}." + }, + "vitalsOutside": { + "aboveOne": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.", + "aboveFew": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.", + "aboveOther": "{metric} est à {value}, au-dessus de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.", + "belowOne": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autre signe vital sort aussi de sa plage.", + "belowFew": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage.", + "belowOther": "{metric} est à {value}, en dessous de ta plage habituelle de {range}, et {count} autres signes vitaux sortent aussi de leur plage." + }, + "sleep": { + "more": "La nuit dernière, tu as dormi {duration}, {diff} de plus que d'habitude.", + "less": "La nuit dernière, tu as dormi {duration}, {diff} de moins que d'habitude.", + "usual": "La nuit dernière, tu as dormi {duration}, proche de ton habitude.", + "plain": "La nuit dernière, tu as dormi {duration}." + }, + "vitalsInRangeOne": "Ton dernier signe vital est dans sa plage habituelle.", + "vitalsInRangeFew": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.", + "vitalsInRangeOther": "Tes {count} derniers signes vitaux sont tous dans leur plage habituelle.", + "vitalInRange": "{metric} est à {value}, dans ta plage habituelle de {range}." + }, + "todayFact": { + "restMode": { + "label": "Mode repos", + "value": "Jour {day}" + }, + "medications": { + "label": "Médicaments", + "value": "{taken} sur {scheduled} pris" + }, + "appointment": { + "label": "Rendez-vous", + "today": "Aujourd'hui {time}, {what}", + "tomorrow": "Demain {time}, {what}" + }, + "sleep": { + "label": "La nuit dernière", + "plain": "{duration}", + "usual": "{duration}, proche de ton habitude", + "more": "{duration}, {diff} de plus que d'habitude", + "less": "{duration}, {diff} de moins que d'habitude" + }, + "vitals": { + "label": "Signes vitaux", + "allInOne": "{count} vérifié, dans ta plage", + "allInFew": "{count} vérifiés, tous dans ta plage", + "allInOther": "{count} vérifiés, tous dans ta plage", + "oneOut": { + "above": "{metric} au-dessus de ta plage", + "below": "{metric} en dessous de ta plage" + }, + "someOut": "{count} sur {total} hors de ta plage" + }, + "cycle": { + "label": "Cycle", + "value": "{phase}, jour {day}", + "dayOnly": "Jour {day}" + } } }, "reportSelection": { diff --git a/messages/it.json b/messages/it.json index b8d636332..ae8c7982b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2877,7 +2877,6 @@ "sourcesFresh": "Aggiornate", "sourcesStale": "Datate", "sourcesFooter": "Il coach legge solo i dati che hai collegato.", - "evidenceLabel": "Su cosa si basa?", "metric": { "bp": "Pressione arteriosa", "weight": "Peso", @@ -2923,14 +2922,6 @@ "skin_temp": "Temperatura cutanea", "workouts": "Allenamenti" }, - "window": { - "last7days": "Ultimi 7 giorni", - "last30days": "Ultimi 30 giorni", - "last90days": "Ultimi 90 giorni", - "lastYear": "ultimi 12 mesi", - "allTime": "tutto il tempo (12 mesi in dettaglio)" - }, - "windowLabel": "Periodo", "settingsAriaLabel": "Impostazioni del coach", "settingsTitle": "Impostazioni del coach", "settingsDescription": "Imposta come il coach ti parla.", @@ -2943,8 +2934,9 @@ "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Dettagliata", "settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.", - "settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).", - "settingsSourcesPointerLink": "Apri il coach", + "settingsSourcesSummary": "Il coach vede {count} aree di dati su {total} e guarda indietro di {window}.", + "settingsSourcesSummaryAll": "Il coach vede {count} aree di dati su {total} e l’intero storico.", + "settingsSourcesPointerLink": "Apri le impostazioni del coach", "settingsExcludeLabel": "Escludi metriche", "settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.", "settingsContextLabel": "Invia contesto", @@ -2956,10 +2948,6 @@ "settingsSave": "Salva", "settingsSaved": "Salvato", "settingsCancel": "Annulla", - "feedbackHelpful": "Utile", - "feedbackUnhelpful": "Poco utile", - "feedbackThanks": "Grazie per il segnale.", - "feedbackError": "Impossibile salvare il feedback", "dailyLimitTitle": "Limite giornaliero raggiunto", "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", "providerRateLimitTitle": "Provider con rate-limit", @@ -3009,7 +2997,23 @@ "settingsClustersLabel": "Dati che il Coach vede", "settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.", "settingsClusterNoData": "Ancora nessun dato", - "sourcesMemberCount": "{count} metriche", + "sourcesMemberCountOne": "1 metrica", + "sourcesMemberCountFew": "{count} metriche", + "sourcesMemberCountOther": "{count} metriche", + "lookback": { + "label": "Fin dove il Coach può guardare indietro", + "option": { + "last7days": "{days} giorni", + "last30days": "{days} giorni", + "last90days": "{days} giorni", + "lastYear": "{months} mesi", + "allTime": "Tutto" + }, + "detailDays": "Il Coach non legge nulla di più vecchio di {days} giorni, in nessuna risposta.", + "detailMonths": "Il Coach non legge nulla di più vecchio di {months} mesi, in nessuna risposta.", + "detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa, gli esami degli ultimi {labMonths} mesi.", + "note": "Il tuo profilo, i farmaci attuali, una malattia in corso e le prossime visite restano visibili; i riepiloghi che richiedono più storico vengono accorciati o omessi. Le risposte date in precedenza dal Coach nelle conversazioni più vecchie possono ancora citare valori più vecchi." + }, "showConversations": "Mostra conversazioni", "hideConversations": "Nascondi conversazioni", "heroGreeting": "Chiedimi qualcosa sui tuoi dati", @@ -3113,7 +3117,34 @@ "errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.", "errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.", "errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.", - "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli." + "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.", + "frame": { + "showPanel": "Mostra conversazioni", + "hidePanel": "Nascondi conversazioni", + "panelDescription": "Le tue conversazioni con il coach, dalla più recente.", + "rowActions": "Azioni per {title}", + "rename": "Rinomina", + "delete": "Elimina", + "modelSection": "Modello", + "provider": "Risponde con", + "modelManaged": "Il modello di questo fornitore si imposta nelle impostazioni IA.", + "noProvider": "Nessun fornitore disponibile", + "saving": "Salvataggio…", + "saveFailed": "Impossibile salvare la modifica.", + "allSettings": "Tutte le impostazioni IA", + "providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA.", + "operatorCodex": "ChatGPT (condiviso dall'operatore)", + "operatorManaged": "Questa connessione è gestita dall'operatore del tuo server. Quando configuri uno dei tuoi fornitori, risponde per primo." + }, + "answer": { + "scrollToBottom": "Vai all'ultimo messaggio", + "rememberDone": "Memorizzato", + "timeYesterday": "ieri {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 area consultata", + "areasDoneFew": "{count} aree consultate", + "areasDoneOther": "{count} aree consultate" + } }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -5090,11 +5121,6 @@ "brief": "Breve", "default": "Predefinito", "detailed": "Dettagliato", - "defaultWindow": "Periodo predefinito", - "last7days": "Ultimi 7 giorni", - "last30days": "Ultimi 30 giorni", - "last90days": "Ultimi 90 giorni", - "allTime": "Tutto il periodo", "excludedMetrics": "Metriche escluse", "dataClusters": "Gruppi di dati", "customClusters": "Usa una selezione personalizzata di gruppi", @@ -10431,11 +10457,13 @@ "searchPlaceholder": "Cerca titolo o nome file…", "typeGroup": "Tipo di documento", "conditionGroup": "Patologia", + "procedureGroup": "Procedure e interventi chirurgici", "yearGroup": "Anno", "clear": "Cancella filtri", "typeAll": "Tutti i tipi", "typeCount": "{count} tipi", "conditionAll": "Tutte le patologie", + "procedureAll": "Tutte le procedure", "yearAll": "Tutti gli anni" }, "card": { @@ -10896,8 +10924,8 @@ }, "daily": { "line": { - "score": "Il tuo punteggio di salute oggi è {score}.", - "allClear": "Oggi nulla richiede la tua attenzione." + "allClear": "Oggi nulla richiede la tua attenzione.", + "scoreReady": "Il tuo punteggio di salute di oggi è pronto." }, "push": { "title": "Il tuo riepilogo mattutino", @@ -10983,7 +11011,30 @@ "sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.", "worthALook": "Da tenere d'occhio", "allClear": "Oggi nulla richiede la tua attenzione.", - "ringLink": "Apri i dettagli di {metric}" + "ringLink": "Apri i dettagli di {metric}", + "heading": "Oggi", + "steadyWeeksOne": "Stabile da {count} settimana", + "steadyWeeksFew": "Stabile da {count} settimane", + "steadyWeeksOther": "Stabile da {count} settimane", + "valueWithUnit": "{value} {unit}", + "rangeWithUnit": "da {low} a {high} {unit}", + "vital": { + "RESTING_HEART_RATE": "Frequenza cardiaca a riposo", + "HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca", + "RESPIRATORY_RATE": "Frequenza respiratoria", + "OXYGEN_SATURATION": "Saturazione di ossigeno", + "BODY_TEMPERATURE": "Temperatura corporea", + "SKIN_TEMPERATURE": "Temperatura cutanea", + "BLOOD_PRESSURE_SYS": "Pressione sistolica", + "BLOOD_PRESSURE_DIA": "Pressione diastolica", + "BLOOD_GLUCOSE": "Glicemia", + "PULSE": "Polso", + "WEIGHT": "Peso", + "generic": "Un parametro vitale" + }, + "steadyAtLeastWeeksOne": "Stabile da almeno {count} settimana", + "steadyAtLeastWeeksFew": "Stabile da almeno {count} settimane", + "steadyAtLeastWeeksOther": "Stabile da almeno {count} settimane" }, "milestone": { "metric": { @@ -11019,6 +11070,68 @@ "body": "Un mese intero con la tua {metric} nel tuo intervallo abituale. Meravigliosamente costante." } } + }, + "lead": { + "vitalOutside": { + "above": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}.", + "below": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}." + }, + "vitalsOutside": { + "aboveOne": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.", + "aboveFew": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.", + "aboveOther": "{metric} è a {value}, sopra il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.", + "belowOne": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche {count} altro parametro vitale è fuori dal suo intervallo.", + "belowFew": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo.", + "belowOther": "{metric} è a {value}, sotto il tuo intervallo abituale di {range}, e anche altri {count} parametri vitali sono fuori dal loro intervallo." + }, + "sleep": { + "more": "Stanotte hai dormito {duration}, {diff} in più del solito.", + "less": "Stanotte hai dormito {duration}, {diff} in meno del solito.", + "usual": "Stanotte hai dormito {duration}, vicino al tuo solito.", + "plain": "Stanotte hai dormito {duration}." + }, + "vitalsInRangeOne": "Il tuo ultimo parametro vitale è nel suo intervallo abituale.", + "vitalsInRangeFew": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.", + "vitalsInRangeOther": "Tutti i tuoi {count} ultimi parametri vitali sono nel loro intervallo abituale.", + "vitalInRange": "{metric} è a {value}, nel tuo intervallo abituale di {range}." + }, + "todayFact": { + "restMode": { + "label": "Modalità riposo", + "value": "Giorno {day}" + }, + "medications": { + "label": "Farmaci", + "value": "{taken} di {scheduled} presi" + }, + "appointment": { + "label": "Appuntamento", + "today": "Oggi {time}, {what}", + "tomorrow": "Domani {time}, {what}" + }, + "sleep": { + "label": "Stanotte", + "plain": "{duration}", + "usual": "{duration}, vicino al solito", + "more": "{duration}, {diff} in più del solito", + "less": "{duration}, {diff} in meno del solito" + }, + "vitals": { + "label": "Parametri vitali", + "allInOne": "{count} controllato, nel tuo intervallo", + "allInFew": "{count} controllati, tutti nel tuo intervallo", + "allInOther": "{count} controllati, tutti nel tuo intervallo", + "oneOut": { + "above": "{metric} sopra il tuo intervallo", + "below": "{metric} sotto il tuo intervallo" + }, + "someOut": "{count} su {total} fuori dal tuo intervallo" + }, + "cycle": { + "label": "Ciclo", + "value": "{phase}, giorno {day}", + "dayOnly": "Giorno {day}" + } } }, "reportSelection": { diff --git a/messages/ko.json b/messages/ko.json index 5dc5fadf4..80d038191 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2877,7 +2877,6 @@ "sourcesFresh": "최신", "sourcesStale": "오래됨", "sourcesFooter": "코치는 연결한 데이터만 읽어요.", - "evidenceLabel": "내가 보고 있는 것", "metric": { "bp": "혈압", "weight": "체중", @@ -2923,14 +2922,6 @@ "skin_temp": "피부 온도", "workouts": "운동" }, - "window": { - "last7days": "최근 7일", - "last30days": "최근 30일", - "last90days": "최근 90일", - "lastYear": "최근 12개월", - "allTime": "전체 기간(상세 12개월)" - }, - "windowLabel": "기간", "settingsAriaLabel": "코치 설정", "settingsTitle": "코치 설정", "settingsDescription": "코치가 대화하는 방식을 조절해요.", @@ -2943,8 +2934,9 @@ "settingsVerbosityDefault": "기본", "settingsVerbosityDetailed": "자세히", "settingsVerbosityConciseHint": "간결한 말투는 이미 답변을 짧게 유지해요 — 켜 두는 동안 답변 길이는 '짧게'로 고정돼요.", - "settingsSourcesPointer": "코치가 참고할 데이터 묶음과 기본 분석 기간은 코치 대화의 '내가 참고하는 것' 레일에서 바로 관리해요.", - "settingsSourcesPointerLink": "코치 열기", + "settingsSourcesSummary": "코치는 데이터 영역 {total}개 중 {count}개를 {window} 전까지 봐요.", + "settingsSourcesSummaryAll": "코치는 데이터 영역 {total}개 중 {count}개를 처음 기록부터 봐요.", + "settingsSourcesPointerLink": "코치 설정 열기", "settingsExcludeLabel": "제외할 지표", "settingsExcludeHint": "선택한 지표의 데이터는 코치가 보지 않아요.", "settingsContextLabel": "포함할 맥락", @@ -2956,10 +2948,6 @@ "settingsSave": "저장", "settingsSaved": "저장됨", "settingsCancel": "취소", - "feedbackHelpful": "도움됨", - "feedbackUnhelpful": "아쉬움", - "feedbackThanks": "알려줘서 고마워요.", - "feedbackError": "피드백을 저장하지 못했어요", "dailyLimitTitle": "하루 한도 도달", "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", "providerRateLimitTitle": "제공자 요청 제한", @@ -3009,7 +2997,23 @@ "settingsClustersLabel": "코치가 보는 데이터", "settingsClustersHint": "코치가 읽을 수 있는 데이터 묶음을 고르세요. 데이터가 많을수록 답변이 풍부해지고 프롬프트도 커져요.", "settingsClusterNoData": "아직 데이터 없음", - "sourcesMemberCount": "지표 {count}개", + "sourcesMemberCountOne": "지표 1개", + "sourcesMemberCountFew": "지표 {count}개", + "sourcesMemberCountOther": "지표 {count}개", + "lookback": { + "label": "코치가 돌아볼 수 있는 기간", + "option": { + "last7days": "{days}일", + "last30days": "{days}일", + "last90days": "{days}일", + "lastYear": "{months}개월", + "allTime": "전체" + }, + "detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.", + "detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.", + "detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.", + "note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요. 예전 대화에서 코치가 이미 한 답변에는 더 오래된 값이 남아 있을 수 있어요." + }, "showConversations": "대화 보기", "hideConversations": "대화 숨기기", "heroGreeting": "내 데이터에 대해 무엇이든 물어보세요", @@ -3113,7 +3117,34 @@ "errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.", "errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.", "errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.", - "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다." + "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.", + "frame": { + "showPanel": "대화 목록 보기", + "hidePanel": "대화 목록 숨기기", + "panelDescription": "코치와 나눈 대화, 최신순이에요.", + "rowActions": "{title} 작업", + "rename": "이름 바꾸기", + "delete": "삭제", + "modelSection": "모델", + "provider": "응답 제공자", + "modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.", + "noProvider": "사용할 수 있는 제공자가 없어요", + "saving": "저장 중…", + "saveFailed": "변경 사항을 저장하지 못했어요.", + "allSettings": "모든 AI 설정", + "providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.", + "operatorCodex": "ChatGPT (운영자 공유)", + "operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다." + }, + "answer": { + "scrollToBottom": "최신 메시지로 이동", + "rememberDone": "기억함", + "timeYesterday": "어제 {time}", + "timeEarlier": "{date} {time}", + "areasDoneOne": "영역 1개 확인", + "areasDoneFew": "영역 {count}개 확인", + "areasDoneOther": "영역 {count}개 확인" + } }, "relativeJustNow": "방금", "relativeMinutesAgoOne": "{count}분 전", @@ -5090,11 +5121,6 @@ "brief": "간략", "default": "기본", "detailed": "상세", - "defaultWindow": "기본 기간", - "last7days": "최근 7일", - "last30days": "최근 30일", - "last90days": "최근 90일", - "allTime": "전체 기간", "excludedMetrics": "제외할 지표", "dataClusters": "데이터 그룹", "customClusters": "그룹 직접 선택하기", @@ -10431,11 +10457,13 @@ "searchPlaceholder": "제목이나 파일명 검색…", "typeGroup": "문서 종류", "conditionGroup": "질환", + "procedureGroup": "시술 및 수술", "yearGroup": "연도", "clear": "필터 해제", "typeAll": "모든 종류", "typeCount": "{count}개 종류", "conditionAll": "모든 질환", + "procedureAll": "모든 시술", "yearAll": "모든 연도" }, "card": { @@ -10896,8 +10924,8 @@ }, "daily": { "line": { - "score": "오늘 건강 점수는 {score}점이에요.", - "allClear": "오늘은 신경 쓸 일이 없어요." + "allClear": "오늘은 신경 쓸 일이 없어요.", + "scoreReady": "오늘의 건강 점수가 준비됐어요." }, "push": { "title": "아침 브리핑", @@ -10983,7 +11011,30 @@ "sleepPending": "어젯밤 수면이 아직 들어오지 않아 오늘 결과는 잠정치예요.", "worthALook": "살펴볼 만한 것", "allClear": "오늘은 신경 쓸 일이 없어요.", - "ringLink": "{metric} 자세히 보기" + "ringLink": "{metric} 자세히 보기", + "heading": "오늘", + "steadyWeeksOne": "{count}주째 안정적", + "steadyWeeksFew": "{count}주째 안정적", + "steadyWeeksOther": "{count}주째 안정적", + "valueWithUnit": "{value} {unit}", + "rangeWithUnit": "{low}~{high} {unit}", + "vital": { + "RESTING_HEART_RATE": "안정 시 심박수", + "HEART_RATE_VARIABILITY": "심박 변이도", + "RESPIRATORY_RATE": "호흡수", + "OXYGEN_SATURATION": "산소포화도", + "BODY_TEMPERATURE": "체온", + "SKIN_TEMPERATURE": "피부 온도", + "BLOOD_PRESSURE_SYS": "수축기 혈압", + "BLOOD_PRESSURE_DIA": "이완기 혈압", + "BLOOD_GLUCOSE": "혈당", + "PULSE": "맥박", + "WEIGHT": "체중", + "generic": "생체 지표" + }, + "steadyAtLeastWeeksOne": "최소 {count}주째 안정적", + "steadyAtLeastWeeksFew": "최소 {count}주째 안정적", + "steadyAtLeastWeeksOther": "최소 {count}주째 안정적" }, "milestone": { "metric": { @@ -11019,6 +11070,68 @@ "body": "{metric} 수치가 꼬박 한 달 동안 평소 범위 안이었어요. 아주 꾸준해요." } } + }, + "lead": { + "vitalOutside": { + "above": "{metric}이(가) {value}로 평소 범위({range})보다 높아요.", + "below": "{metric}이(가) {value}로 평소 범위({range})보다 낮아요." + }, + "vitalsOutside": { + "aboveOne": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.", + "aboveFew": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.", + "aboveOther": "{metric}이(가) {value}로 평소 범위({range})보다 높고, 다른 지표 {count}개도 범위를 벗어났어요.", + "belowOne": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.", + "belowFew": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요.", + "belowOther": "{metric}이(가) {value}로 평소 범위({range})보다 낮고, 다른 지표 {count}개도 범위를 벗어났어요." + }, + "sleep": { + "more": "어젯밤 {duration} 잤어요. 평소보다 {diff} 더 잤어요.", + "less": "어젯밤 {duration} 잤어요. 평소보다 {diff} 덜 잤어요.", + "usual": "어젯밤 {duration} 잤어요. 평소와 비슷해요.", + "plain": "어젯밤 {duration} 잤어요." + }, + "vitalsInRangeOne": "최근 생체 지표가 평소 범위 안에 있어요.", + "vitalsInRangeFew": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.", + "vitalsInRangeOther": "최근 생체 지표 {count}개가 모두 평소 범위 안에 있어요.", + "vitalInRange": "{metric}이(가) {value}로 평소 범위({range}) 안에 있어요." + }, + "todayFact": { + "restMode": { + "label": "휴식 모드", + "value": "{day}일째" + }, + "medications": { + "label": "복약", + "value": "{scheduled}회 중 {taken}회 복용" + }, + "appointment": { + "label": "예약", + "today": "오늘 {time}, {what}", + "tomorrow": "내일 {time}, {what}" + }, + "sleep": { + "label": "어젯밤", + "plain": "{duration}", + "usual": "{duration}, 평소와 비슷", + "more": "{duration}, 평소보다 {diff} 더", + "less": "{duration}, 평소보다 {diff} 덜" + }, + "vitals": { + "label": "생체 지표", + "allInOne": "{count}개 확인, 범위 안", + "allInFew": "{count}개 확인, 모두 범위 안", + "allInOther": "{count}개 확인, 모두 범위 안", + "oneOut": { + "above": "{metric} 범위보다 높음", + "below": "{metric} 범위보다 낮음" + }, + "someOut": "{total}개 중 {count}개 범위 밖" + }, + "cycle": { + "label": "주기", + "value": "{phase}, {day}일째", + "dayOnly": "{day}일째" + } } }, "reportSelection": { diff --git a/messages/pl.json b/messages/pl.json index b967ecdc4..809bc6c8e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2877,7 +2877,6 @@ "sourcesFresh": "Aktualne", "sourcesStale": "Nieaktualne", "sourcesFooter": "Coach czyta tylko dane, które podłączyłeś.", - "evidenceLabel": "Na czym to się opiera?", "metric": { "bp": "Ciśnienie krwi", "weight": "Waga", @@ -2923,14 +2922,6 @@ "skin_temp": "Temperatura skóry", "workouts": "Treningi" }, - "window": { - "last7days": "Ostatnie 7 dni", - "last30days": "Ostatnie 30 dni", - "last90days": "Ostatnie 90 dni", - "lastYear": "ostatnie 12 miesięcy", - "allTime": "cały okres (12 miesięcy szczegółowo)" - }, - "windowLabel": "Okres", "settingsAriaLabel": "Ustawienia coacha", "settingsTitle": "Ustawienia coacha", "settingsDescription": "Ustaw, jak coach do ciebie mówi.", @@ -2943,8 +2934,9 @@ "settingsVerbosityDefault": "Standardowa", "settingsVerbosityDetailed": "Szczegółowa", "settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.", - "settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).", - "settingsSourcesPointerLink": "Otwórz coacha", + "settingsSourcesSummary": "Coach widzi {count} z {total} obszarów danych i patrzy {window} wstecz.", + "settingsSourcesSummaryAll": "Coach widzi {count} z {total} obszarów danych i całą historię.", + "settingsSourcesPointerLink": "Otwórz ustawienia coacha", "settingsExcludeLabel": "Wyklucz metryki", "settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.", "settingsContextLabel": "Wysyłaj kontekst", @@ -2956,10 +2948,6 @@ "settingsSave": "Zapisz", "settingsSaved": "Zapisano", "settingsCancel": "Anuluj", - "feedbackHelpful": "Przydatne", - "feedbackUnhelpful": "Mało przydatne", - "feedbackThanks": "Dzięki za sygnał.", - "feedbackError": "Nie udało się zapisać opinii", "dailyLimitTitle": "Osiągnięto dzienny limit", "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", "providerRateLimitTitle": "Dostawca z ograniczeniem", @@ -3009,7 +2997,23 @@ "settingsClustersLabel": "Dane widziane przez Coacha", "settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.", "settingsClusterNoData": "Brak danych", - "sourcesMemberCount": "{count} wskaźników", + "sourcesMemberCountOne": "1 wskaźnik", + "sourcesMemberCountFew": "{count} wskaźniki", + "sourcesMemberCountOther": "{count} wskaźników", + "lookback": { + "label": "Jak daleko wstecz może patrzeć Coach", + "option": { + "last7days": "{days} dni", + "last30days": "{days} dni", + "last90days": "{days} dni", + "lastYear": "{months} miesięcy", + "allTime": "Wszystko" + }, + "detailDays": "Coach nie czyta niczego starszego niż {days} dni, w żadnej odpowiedzi.", + "detailMonths": "Coach nie czyta niczego starszego niż {months} miesięcy, w żadnej odpowiedzi.", + "detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz, wyniki badań z ostatnich {labMonths} miesięcy.", + "note": "Twój profil, obecne leki, trwająca choroba i nadchodzące wizyty pozostają widoczne; podsumowania wymagające dłuższej historii są skracane lub pomijane. Odpowiedzi, których Coach udzielił wcześniej w starszych rozmowach, mogą nadal przytaczać starsze wartości." + }, "showConversations": "Pokaż rozmowy", "hideConversations": "Ukryj rozmowy", "heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych", @@ -3113,7 +3117,34 @@ "errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.", "errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.", "errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.", - "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły." + "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.", + "frame": { + "showPanel": "Pokaż rozmowy", + "hidePanel": "Ukryj rozmowy", + "panelDescription": "Twoje rozmowy z coachem, od najnowszej.", + "rowActions": "Działania dla {title}", + "rename": "Zmień nazwę", + "delete": "Usuń", + "modelSection": "Model", + "provider": "Odpowiada przez", + "modelManaged": "Model tego dostawcy ustawisz w ustawieniach AI.", + "noProvider": "Brak dostępnego dostawcy", + "saving": "Zapisywanie…", + "saveFailed": "Nie udało się zapisać zmiany.", + "allSettings": "Wszystkie ustawienia AI", + "providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI.", + "operatorCodex": "ChatGPT (udostępniony przez operatora)", + "operatorManaged": "Tym połączeniem zarządza operator Twojego serwera. Gdy skonfigurujesz własnego dostawcę, to on odpowie jako pierwszy." + }, + "answer": { + "scrollToBottom": "Przejdź do najnowszej wiadomości", + "rememberDone": "Zapamiętano", + "timeYesterday": "wczoraj {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "Sprawdzono 1 obszar", + "areasDoneFew": "Sprawdzono {count} obszary", + "areasDoneOther": "Sprawdzono {count} obszarów" + } }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -5090,11 +5121,6 @@ "brief": "Krótka", "default": "Domyślna", "detailed": "Szczegółowa", - "defaultWindow": "Domyślny okres", - "last7days": "Ostatnie 7 dni", - "last30days": "Ostatnie 30 dni", - "last90days": "Ostatnie 90 dni", - "allTime": "Cały okres", "excludedMetrics": "Wykluczone metryki", "dataClusters": "Grupy danych", "customClusters": "Użyj własnego wyboru grup", @@ -10431,11 +10457,13 @@ "searchPlaceholder": "Szukaj tytułu lub nazwy pliku…", "typeGroup": "Typ dokumentu", "conditionGroup": "Schorzenie", + "procedureGroup": "Zabiegi i operacje", "yearGroup": "Rok", "clear": "Wyczyść filtry", "typeAll": "Wszystkie typy", "typeCount": "{count} typy", "conditionAll": "Wszystkie schorzenia", + "procedureAll": "Wszystkie zabiegi", "yearAll": "Wszystkie lata" }, "card": { @@ -10896,8 +10924,8 @@ }, "daily": { "line": { - "score": "Twój dzisiejszy wynik zdrowia to {score}.", - "allClear": "Dziś nic nie wymaga Twojej uwagi." + "allClear": "Dziś nic nie wymaga Twojej uwagi.", + "scoreReady": "Twój dzisiejszy wynik zdrowia jest gotowy." }, "push": { "title": "Twoje poranne podsumowanie", @@ -10983,7 +11011,30 @@ "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", - "ringLink": "Otwórz szczegóły: {metric}" + "ringLink": "Otwórz szczegóły: {metric}", + "heading": "Dziś", + "steadyWeeksOne": "Stabilnie od {count} tygodnia", + "steadyWeeksFew": "Stabilnie od {count} tygodni", + "steadyWeeksOther": "Stabilnie od {count} tygodni", + "valueWithUnit": "{value} {unit}", + "rangeWithUnit": "{low} do {high} {unit}", + "vital": { + "RESTING_HEART_RATE": "Tętno spoczynkowe", + "HEART_RATE_VARIABILITY": "Zmienność rytmu serca", + "RESPIRATORY_RATE": "Częstość oddechów", + "OXYGEN_SATURATION": "Saturacja", + "BODY_TEMPERATURE": "Temperatura ciała", + "SKIN_TEMPERATURE": "Temperatura skóry", + "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", + "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe", + "BLOOD_GLUCOSE": "Glukoza we krwi", + "PULSE": "Tętno", + "WEIGHT": "Waga", + "generic": "Parametr życiowy" + }, + "steadyAtLeastWeeksOne": "Stabilnie od co najmniej {count} tygodnia", + "steadyAtLeastWeeksFew": "Stabilnie od co najmniej {count} tygodni", + "steadyAtLeastWeeksOther": "Stabilnie od co najmniej {count} tygodni" }, "milestone": { "metric": { @@ -11019,6 +11070,68 @@ "body": "Cały miesiąc z {metric} w zwykłym zakresie. Wspaniale stabilnie." } } + }, + "lead": { + "vitalOutside": { + "above": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}.", + "below": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}." + }, + "vitalsOutside": { + "aboveOne": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.", + "aboveFew": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.", + "aboveOther": "{metric}: {value}, powyżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów.", + "belowOne": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} inny parametr.", + "belowFew": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem są też {count} inne parametry.", + "belowOther": "{metric}: {value}, poniżej Twojego zwykłego zakresu {range}. Poza zakresem jest też {count} innych parametrów." + }, + "sleep": { + "more": "Ostatnia noc: {duration} snu, o {diff} dłużej niż zwykle.", + "less": "Ostatnia noc: {duration} snu, o {diff} krócej niż zwykle.", + "usual": "Ostatnia noc: {duration} snu, mniej więcej tyle co zwykle.", + "plain": "Ostatnia noc: {duration} snu." + }, + "vitalsInRangeOne": "Twój ostatni parametr życiowy mieści się w zwykłym zakresie.", + "vitalsInRangeFew": "Wszystkie {count} ostatnie parametry życiowe mieszczą się w zwykłym zakresie.", + "vitalsInRangeOther": "Wszystkie {count} ostatnich parametrów życiowych mieści się w zwykłym zakresie.", + "vitalInRange": "{metric}: {value}, w Twoim zwykłym zakresie {range}." + }, + "todayFact": { + "restMode": { + "label": "Tryb odpoczynku", + "value": "Dzień {day}" + }, + "medications": { + "label": "Leki", + "value": "Przyjęto {taken} z {scheduled}" + }, + "appointment": { + "label": "Wizyta", + "today": "Dziś {time}, {what}", + "tomorrow": "Jutro {time}, {what}" + }, + "sleep": { + "label": "Ostatnia noc", + "plain": "{duration}", + "usual": "{duration}, jak zwykle", + "more": "{duration}, o {diff} dłużej niż zwykle", + "less": "{duration}, o {diff} krócej niż zwykle" + }, + "vitals": { + "label": "Parametry życiowe", + "allInOne": "Sprawdzono {count}, w zakresie", + "allInFew": "Sprawdzono {count}, wszystkie w zakresie", + "allInOther": "Sprawdzono {count}, wszystkie w zakresie", + "oneOut": { + "above": "{metric} powyżej zakresu", + "below": "{metric} poniżej zakresu" + }, + "someOut": "{count} z {total} poza zakresem" + }, + "cycle": { + "label": "Cykl", + "value": "{phase}, dzień {day}", + "dayOnly": "Dzień {day}" + } } }, "reportSelection": { diff --git a/package.json b/package.json index 32ef4824a..c8e1dc505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.40.1", + "version": "1.40.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 0c7bb5af9..a1efeb60b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.40.1"; + /* @sw-version-fallback */ "v1.40.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 33167d40a..60eb03e4d 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -1766,10 +1766,32 @@ async function seed() { [convoId, userId, "Blood pressure trend", convoStart], ); + // The provenance uses the contract tokens (`bp`, `resting_hr`, `sleep`): + // the client names an area only through a known token, so a token it + // cannot name never reaches the page. The steps give the answer its + // "Looked at 3 sources" header the way a live turn would; they carry no + // counts, since a count is the server's tally and the seed does not + // run a tool. + const seedStep = ( + id: string, + tool: string, + domain: string, + domainLabel: string, + ) => ({ + id, + tool, + labelKey: "coach.step.readWindow", + label: `Checking: ${domainLabel}, last 30 days`, + domain, + window: "last30days", + status: "done", + }); const coachTurns: Array<{ role: string; content: string; metricSourceJson?: string; + tokensUsed?: number; + model?: string; }> = [ { role: "user", @@ -1785,9 +1807,16 @@ async function seed() { "paying off — worth staying consistent with the morning measurements " + "so the trend stays easy to read.", metricSourceJson: JSON.stringify({ - window: "last30days", - metrics: ["bloodPressure", "restingHeartRate", "sleep"], + windows: ["last30days"], + metrics: ["bp", "resting_hr", "sleep"], + steps: [ + seedStep("s1", "get_metric_series", "bp", "Blood pressure"), + seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"), + seedStep("s3", "get_sleep", "sleep", "Sleep"), + ], }), + tokensUsed: 1840, + model: aiModel, }, ]; for (let t = 0; t < coachTurns.length; t++) { @@ -1795,8 +1824,8 @@ async function seed() { const ts = new Date(convoStart.getTime() + t * 60_000); const encrypted = Buffer.from(encryptToBytes(turn.content)); await client.query( - `INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + `INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, tokens_used, model, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ cuid(), convoId, @@ -1805,6 +1834,8 @@ async function seed() { turn.metricSourceJson ?? null, turn.role === "assistant" ? aiProvider : null, turn.role === "assistant" ? "demo" : null, + turn.tokensUsed ?? null, + turn.model ?? null, ts, ], ); diff --git a/src/__tests__/coach-stream-lifecycle-guard.test.ts b/src/__tests__/coach-stream-lifecycle-guard.test.ts index 466a34f93..7cb5fb0b7 100644 --- a/src/__tests__/coach-stream-lifecycle-guard.test.ts +++ b/src/__tests__/coach-stream-lifecycle-guard.test.ts @@ -63,18 +63,26 @@ describe("Coach streaming lifecycle", () => { "components/insights/coach-panel/coach-conversation.tsx", ); - // Isolate the HistoryRail onSelect callback body. + // One switch handler, shared by the drawer's history tray and the page's + // conversations panel, so neither surface can skip the reset. const match = source.match( - / { diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 927aaf7b6..bd9e68c89 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -173,7 +173,14 @@ const PINNED_AFFORDANCES: Record< CheckCircle2: 2, "toast.success": 1, }, - "src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 }, + // The Coach message action row: the copy tick after the clipboard write + // resolved, and the remember icon and toast only for the self-context entry + // the tap wrote (`adopted`); an entry that was already there gets a neutral + // icon and `toast.info`. + "src/components/insights/coach-panel/message-actions.tsx": { + "text-success": 2, + "toast.success": 1, + }, // The copy tick on a result table: shown only after the clipboard write // resolved, so it reports a copy that happened; nothing is written to the // record. diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index 81f145a57..738408f41 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -28,6 +28,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { isCycleEnabled } from "@/lib/cycle/gate"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { toCycleProfileDTO } from "@/lib/cycle/dto"; import { cyclePrefsSchema } from "@/lib/validations/cycle"; @@ -118,6 +119,9 @@ export const PATCH = apiHandler(async (req: Request) => { }); const resolved = isCycleEnabled(user.gender, updated); + // The typical lengths, the prediction switch and tracking itself all feed + // the dashboard's cycle line, which is cached in the digest extras cell. + invalidateUserHealthContext(user.id); await auditLog("user.cycle-prefs.update", { userId: user.id, diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index 14f636b64..9ae0648ba 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -71,6 +71,9 @@ const DIGEST: DailyDigest = { }, topSignal: null, briefingLead: "Blood pressure is holding steady.", + lead: { text: "Blood pressure is holding steady.", source: "briefing" }, + today: [], + restMode: null, line: "Blood pressure is holding steady.", worthALook: [], justIn: null, diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index a83d87578..81319039f 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -580,6 +580,82 @@ describe("GET /api/documents/inbound (list)", () => { expect(body.data.nextCursor).toBe("d1"); }); + it("narrows to one procedure through the visit link, owner-scoped", async () => { + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + docRow({ id: "d1" }), + ] as never); + const res = await get( + new Request("http://localhost/api/documents/inbound?encounterId=enc-1"), + ); + expect(res.status).toBe(200); + const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]! + .where!; + // The link filter rides beside the owner and the tombstone predicates, so + // another account's visit id can only ever match nothing of this one. + expect(where).toEqual({ + userId: "user-1", + deletedAt: null, + encounterLinks: { some: { encounterId: "enc-1" } }, + }); + }); + + it("AND-combines the procedure filter with kind, year and condition", async () => { + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never); + const res = await get( + new Request( + "http://localhost/api/documents/inbound?encounterId=enc-1&kind=IMAGING&year=2025&episodeId=ep-1", + ), + ); + expect(res.status).toBe(200); + const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]! + .where!; + expect(where.encounterLinks).toEqual({ some: { encounterId: "enc-1" } }); + expect(where.conditionLinks).toEqual({ some: { episodeId: "ep-1" } }); + expect(where.kind).toEqual({ in: ["IMAGING"] }); + expect(where.documentDate).toEqual({ + gte: new Date(Date.UTC(2025, 0, 1)), + lt: new Date(Date.UTC(2026, 0, 1)), + }); + expect(where.userId).toBe("user-1"); + }); + + it("pages a procedure-filtered list on the keyset cursor", async () => { + const rows = Array.from({ length: 3 }, (_, i) => docRow({ id: `d${i}` })); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never); + const res = await get( + new Request( + "http://localhost/api/documents/inbound?encounterId=enc-1&limit=2&cursor=d9", + ), + ); + const body = await res.json(); + expect(body.data.documents.map((d: { id: string }) => d.id)).toEqual([ + "d0", + "d1", + ]); + expect(body.data.nextCursor).toBe("d1"); + const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!; + expect(arg.where!.encounterLinks).toEqual({ + some: { encounterId: "enc-1" }, + }); + expect(arg.cursor).toEqual({ id: "d9" }); + expect(arg.skip).toBe(1); + expect(arg.take).toBe(3); + }); + + it("422s an empty or over-long encounterId", async () => { + let res = await get( + new Request("http://localhost/api/documents/inbound?encounterId="), + ); + expect(res.status).toBe(422); + res = await get( + new Request( + `http://localhost/api/documents/inbound?encounterId=${"x".repeat(41)}`, + ), + ); + expect(res.status).toBe(422); + expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled(); + }); + it("422s on a bad sort value", async () => { const res = await get( new Request("http://localhost/api/documents/inbound?sort=nope"), diff --git a/src/app/api/documents/inbound/usage/__tests__/route.test.ts b/src/app/api/documents/inbound/usage/__tests__/route.test.ts new file mode 100644 index 000000000..266365777 --- /dev/null +++ b/src/app/api/documents/inbound/usage/__tests__/route.test.ts @@ -0,0 +1,202 @@ +/** + * `GET /api/documents/inbound/usage` — the procedure choices of the vault's + * filter bar. + * + * The reason and body site are ciphertext in the fixture as they are in the + * database, so a route that published the stored bytes, or skipped the + * decrypt, goes red. The fake honours nothing about the `where`, so the test + * asserts the `where` it was handed: a read that forgot the kind, the owner, + * or the live-document condition would otherwise pass against a fixture of + * linked procedures only. + * + * Mutation checks (each run, each seen red): + * - drop `kind: "PROCEDURE"` from the read → "reads only the owner's live + * procedures with a live document" goes red; + * - read the procedures without the `visible("profile")` check → "a grant + * without the visits section gets no procedure" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const mocks = vi.hoisted(() => ({ + encounterFindMany: vi.fn(), + requireRecordAuth: vi.fn(), + visible: vi.fn((_domain: string) => true), + actingDomainVisibility: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireRecordAuth: mocks.requireRecordAuth, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => undefined, +})); +vi.mock("@/lib/db", () => ({ + prisma: { + encounter: { findMany: mocks.encounterFindMany }, + $queryRaw: vi.fn(async () => [{ used: BigInt(0) }]), + documentContentIndex: { count: vi.fn(async () => 0) }, + inboundDocument: { count: vi.fn(async () => 0) }, + }, +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/documents/upload-policy", () => ({ + DOCUMENT_ACCEPTED_EXTENSIONS: [".pdf"], + resolveDocumentLimits: vi.fn(async () => ({ + quotaBytes: 1000, + maxFileBytes: 100, + })), +})); +vi.mock("@/lib/documents/provider-order", () => ({ + resolveDocumentAiCapability: vi.fn(async () => ({ available: false })), +})); +vi.mock("@/lib/links", () => ({ + listDistinctTargets: vi.fn(async () => []), + listTargetsBySource: vi.fn(async () => new Map()), + replaceTargets: vi.fn(), +})); +vi.mock("@/lib/sharing/acting-domains", () => ({ + actingDomainVisibility: mocks.actingDomainVisibility, +})); + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { GET } from "../route"; + +const handler = GET as unknown as () => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.requireRecordAuth.mockResolvedValue({ + user: { id: "owner-1" }, + grantId: null, + }); + mocks.visible.mockImplementation(() => true); + mocks.actingDomainVisibility.mockImplementation(async () => mocks.visible); + mocks.encounterFindMany.mockResolvedValue([ + { + id: "enc-knee", + occurredAt: new Date("2025-10-02T08:00:00Z"), + reasonEncrypted: encryptToBytes("Knee arthroscopy"), + bodySiteEncrypted: encryptToBytes("Knee"), + laterality: "LEFT", + practitioner: null, + }, + { + id: "enc-unnamed", + occurredAt: new Date("2024-05-10T08:00:00Z"), + reasonEncrypted: null, + bodySiteEncrypted: null, + laterality: null, + practitioner: { name: "Day clinic" }, + }, + ]); +}); + +async function read() { + const res = await handler(); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +describe("GET /api/documents/inbound/usage — linked procedures", () => { + it("publishes the linked procedures with the reason and site decrypted", async () => { + const data = await read(); + expect(data.linkedProcedures).toEqual([ + { + encounterId: "enc-knee", + occurredAt: "2025-10-02T08:00:00.000Z", + reason: "Knee arthroscopy", + bodySite: "Knee", + laterality: "LEFT", + practitionerName: null, + }, + { + encounterId: "enc-unnamed", + occurredAt: "2024-05-10T08:00:00.000Z", + reason: null, + bodySite: null, + laterality: null, + practitionerName: "Day clinic", + }, + ]); + }); + + it("reads only the owner's live procedures with a live document, label columns only", async () => { + await read(); + expect(mocks.encounterFindMany).toHaveBeenCalledTimes(1); + const arg = mocks.encounterFindMany.mock.calls[0]![0]; + expect(arg.where).toEqual({ + userId: "owner-1", + deletedAt: null, + kind: "PROCEDURE", + documentLinks: { + some: { userId: "owner-1", document: { deletedAt: null } }, + }, + }); + // No outcome, no practitioner contact fields leave the database. + expect(Object.keys(arg.select).sort()).toEqual([ + "bodySiteEncrypted", + "id", + "laterality", + "occurredAt", + "practitioner", + "reasonEncrypted", + ]); + expect(arg.select.practitioner).toEqual({ select: { name: true } }); + expect(arg.take).toBeGreaterThan(0); + }); + + it("reads the record owner's procedures for a delegate whose grant covers visits", async () => { + mocks.requireRecordAuth.mockResolvedValue({ + user: { id: "owner-1" }, + grantId: "grant-1", + }); + const data = await read(); + expect(mocks.actingDomainVisibility).toHaveBeenCalledWith( + expect.anything(), + "grant-1", + ); + expect(mocks.visible).toHaveBeenCalledWith("profile"); + expect(data.linkedProcedures).toHaveLength(2); + expect(mocks.encounterFindMany.mock.calls[0]![0].where.userId).toBe( + "owner-1", + ); + }); + + it("a grant without the visits section gets no procedure, and nothing is read", async () => { + mocks.requireRecordAuth.mockResolvedValue({ + user: { id: "owner-1" }, + grantId: "grant-1", + }); + mocks.visible.mockImplementation((domain) => domain !== "profile"); + const data = await read(); + expect(data.linkedProcedures).toEqual([]); + expect(mocks.encounterFindMany).not.toHaveBeenCalled(); + // The positive control: the rest of the payload is still served. + expect(data.quotaBytes).toBe(1000); + expect(data.linkedEpisodes).toEqual([]); + }); + + it("reads one undecryptable reason as unnamed instead of failing the vault", async () => { + mocks.encounterFindMany.mockResolvedValue([ + { + id: "enc-broken", + occurredAt: new Date("2025-01-01T00:00:00Z"), + reasonEncrypted: new Uint8Array([1, 2, 3, 4]), + bodySiteEncrypted: null, + laterality: null, + practitioner: null, + }, + ]); + const data = await read(); + expect(data.linkedProcedures).toEqual([ + expect.objectContaining({ encounterId: "enc-broken", reason: null }), + ]); + }); +}); diff --git a/src/app/api/documents/inbound/usage/route.ts b/src/app/api/documents/inbound/usage/route.ts index ca2524bb8..74a96dc1c 100644 --- a/src/app/api/documents/inbound/usage/route.ts +++ b/src/app/api/documents/inbound/usage/route.ts @@ -15,9 +15,11 @@ import { resolveDocumentLimits, } from "@/lib/documents/upload-policy"; import { resolveDocumentAiCapability } from "@/lib/documents/provider-order"; +import { loadLinkedProcedures } from "@/lib/documents/links"; import { listDistinctTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; export const dynamic = "force-dynamic"; @@ -32,53 +34,75 @@ export const dynamic = "force-dynamic"; * credential, endpoint or token crosses the wire, and every AI action it would * gate stays refused under a switch. * + * The procedure choices are the one part that reads past the documents + * section: their labels are the visit's own reason and body site, which belong + * to the visits section (`("read", "profile")`, the procedure history's own + * declaration). A grant that does not cover it gets an empty list, and the + * filter bar renders no procedure control — the same shape an owner with no + * filed procedure sees. + * * No write arm exists on this route. */ export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "documents"); + const { user, grantId } = await requireRecordAuth("read", "documents"); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const [limits, rows, linkRows, capability, indexedCount, totalCount] = - await Promise.all([ - resolveDocumentLimits(user.id), - prisma.$queryRaw>` + const visible = await actingDomainVisibility(prisma, grantId); + const [ + limits, + rows, + linkRows, + linkedProcedures, + capability, + indexedCount, + totalCount, + ] = await Promise.all([ + resolveDocumentLimits(user.id), + prisma.$queryRaw>` SELECT COALESCE(SUM(byte_size), 0)::bigint AS used FROM inbound_documents WHERE user_id = ${user.id} `, - // Episodes that carry at least one LIVE document link — the filter - // bar's condition chips. Sourced here (not from the loaded corpus) so - // a chip exists even when its documents sit pages deep in the - // timeline; one indexed grouped query, no blobs. - listDistinctTargets(prisma, { - userId: user.id, - sourceKind: "document", - targetKind: "conditionEpisode", - }), - // Whether an AI action can run for this caller (assist + indexing share - // the same provider precondition). Honest availability so the UI never - // offers what the endpoint would refuse: the document provider order and - // the `documentAi` capability for this record, the same answer the - // capability probe gives. - resolveDocumentAiCapability(user.id), - // Content-index coverage: how many live documents are indexed … - // Scope the count to LIVE documents: a soft-deleted document keeps its - // index row (cascade fires on hard purge only), so an unscoped count - // would drift above `totalCount` and read the gauge past 100 %. - prisma.documentContentIndex.count({ - where: { userId: user.id, document: { deletedAt: null } }, - }), - // … out of how many live documents there are. - prisma.inboundDocument.count({ - where: { userId: user.id, deletedAt: null }, - }), - ]); + // Episodes that carry at least one LIVE document link — the filter + // bar's condition chips. Sourced here (not from the loaded corpus) so + // a chip exists even when its documents sit pages deep in the + // timeline; one indexed grouped query, no blobs. + listDistinctTargets(prisma, { + userId: user.id, + sourceKind: "document", + targetKind: "conditionEpisode", + }), + // Procedures that carry at least one live document link — the filter + // bar's procedure choices, only where the visits section is readable. + visible("profile") ? loadLinkedProcedures(user.id) : Promise.resolve([]), + // Whether an AI action can run for this caller (assist + indexing share + // the same provider precondition). Honest availability so the UI never + // offers what the endpoint would refuse: the document provider order and + // the `documentAi` capability for this record, the same answer the + // capability probe gives. + resolveDocumentAiCapability(user.id), + // Content-index coverage: how many live documents are indexed … + // Scope the count to LIVE documents: a soft-deleted document keeps its + // index row (cascade fires on hard purge only), so an unscoped count + // would drift above `totalCount` and read the gauge past 100 %. + prisma.documentContentIndex.count({ + where: { userId: user.id, document: { deletedAt: null } }, + }), + // … out of how many live documents there are. + prisma.inboundDocument.count({ + where: { userId: user.id, deletedAt: null }, + }), + ]); const usedBytes = Number(rows[0]?.used ?? 0); annotate({ action: { name: "documents.vault.usage" }, - meta: { usedBytes, quotaBytes: limits.quotaBytes }, + meta: { + usedBytes, + quotaBytes: limits.quotaBytes, + linkedProcedures: linkedProcedures.length, + }, }); const payload: DocumentUsageDto = { @@ -90,6 +114,7 @@ export const GET = apiHandler(async () => { episodeId: row.id, name: row.label, })), + linkedProcedures, assistAvailable: capability.available, contentIndex: { enabled: capability.available, diff --git a/src/app/api/insights/provider-chain/__tests__/route.test.ts b/src/app/api/insights/provider-chain/__tests__/route.test.ts index efb66afab..e66ca14e8 100644 --- a/src/app/api/insights/provider-chain/__tests__/route.test.ts +++ b/src/app/api/insights/provider-chain/__tests__/route.test.ts @@ -26,6 +26,12 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: vi.fn(), + probeProviderChain: vi.fn(async () => ({ + entries: [], + localOcrEnabled: false, + managedBy: null, + availableTypes: ["codex", "openai", "admin-openai"], + })), })); vi.mock("@/lib/ai/provider-runner", () => ({ @@ -46,7 +52,7 @@ vi.mock("@/lib/db", () => ({ })); import { GET } from "../route"; -import { resolveProviderChain } from "@/lib/ai/provider"; +import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider"; import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; import { prisma } from "@/lib/db"; @@ -176,4 +182,37 @@ describe("GET /api/insights/provider-chain", () => { true, ); }); + + it("marks an entry without a credential as unavailable, enabled or not", async () => { + // The Coach's provider select offers only available entries; an entry + // marked available without a key was saved to the front of the chain + // and never answered. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProviderChain: [ + { providerType: "codex", priority: 1, enabled: true }, + { providerType: "anthropic", priority: 2, enabled: true }, + { providerType: "openai", priority: 3, enabled: false }, + { providerType: "local", priority: 4, enabled: true }, + ], + } as never); + vi.mocked(probeProviderChain).mockResolvedValueOnce({ + entries: [{ providerType: "codex", vision: false }], + localOcrEnabled: false, + managedBy: "user", + availableTypes: ["codex", "openai"], + }); + vi.mocked(resolveProviderChain).mockResolvedValue([ + { providerType: "codex", instance: { type: "codex" } as never }, + ]); + vi.mocked(getLastWorkingProvider).mockReturnValue(null); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as Envelope; + expect(body.data?.configuredChain).toEqual([ + { providerType: "codex", enabled: true, available: true }, + { providerType: "anthropic", enabled: true, available: false }, + { providerType: "openai", enabled: false, available: true }, + { providerType: "local", enabled: true, available: false }, + ]); + }); }); diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 477cb8159..8ced0a544 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -7,7 +7,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { resolveProviderChain } from "@/lib/ai/provider"; +import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider"; import { forgetLastWorkingProvider, getLastWorkingProvider, @@ -26,11 +26,11 @@ import { providerChainPutSchema } from "@/lib/validations/ai-provider"; * in-process cache, when one is set. Surfaces "Active: codex * (cached: openai)" so a user can see the runner has rerouted to * a fallback without digging through logs. - * - `configuredChain`: ordered list of `{providerType, available}` - * entries. `available` is always true here since the chain - * resolver only returns instantiable entries; the field is wired - * for the v1.4.17 UX where unconfigured slots should still render - * in the list with a "needs setup" pill. + * - `configuredChain`: ordered list of `{providerType, enabled, + * available}` entries. `available` is the presence probe's own + * answer for that entry's credential (`probeProviderChain`), enabled + * or not: an entry without a key, address or sign-in is listed but + * cannot answer, so a picker must not offer it. * * v1.4.16 phase B2 added the matching `PUT` for the new dropdown-driven * settings UX — the user reorders/toggles entries in the section and @@ -58,7 +58,11 @@ export const GET = apiHandler(async () => { }); const persisted = parseProviderChain(userRow?.aiProviderChain ?? null); - const resolved = await resolveProviderChain(user.id); + const [resolved, presence] = await Promise.all([ + resolveProviderChain(user.id), + probeProviderChain(user.id), + ]); + const availableTypes = new Set(presence.availableTypes ?? []); const cached = getLastWorkingProvider(user.id); annotate({ @@ -77,7 +81,7 @@ export const GET = apiHandler(async () => { configuredChain: persisted.map((entry) => ({ providerType: entry.providerType, enabled: entry.enabled, - available: true, + available: availableTypes.has(entry.providerType), })), }); }); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 457bf92e0..d42ffa50f 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -24,8 +24,8 @@ import { useCoachConversationHistory, useDeleteCoachConversationWithUndo, } from "@/components/insights/coach-panel/use-coach"; -import type { CoachConversationDTO } from "@/lib/ai/coach/types"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability"; import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel"; @@ -58,58 +58,6 @@ import { cn } from "@/lib/utils"; * wrapper matches the Coach page so the two surfaces share one chrome. */ -type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier"; - -interface RecencyGroup { - id: RecencyGroupId; - labelKey: string; - conversations: CoachConversationDTO[]; -} - -/** - * Bucket conversations by LOCAL calendar recency. `updatedAt` is an ISO - * string; comparisons run against local day boundaries so "Today" tracks - * the user's own midnight, not UTC. - */ -function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] { - const now = new Date(); - const startOfToday = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - ).getTime(); - const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; - // "This week" = the six days before yesterday (a rolling 7-day window - // that already excludes today + yesterday, which have their own groups). - const startOfWeek = startOfToday - 6 * 24 * 60 * 60 * 1000; - - const groups: Record = { - today: [], - yesterday: [], - thisWeek: [], - earlier: [], - }; - - for (const conversation of conversations) { - const updated = new Date(conversation.updatedAt).getTime(); - if (updated >= startOfToday) groups.today.push(conversation); - else if (updated >= startOfYesterday) groups.yesterday.push(conversation); - else if (updated >= startOfWeek) groups.thisWeek.push(conversation); - else groups.earlier.push(conversation); - } - - return ( - [ - { id: "today", labelKey: "insights.coach.history.groupToday" }, - { id: "yesterday", labelKey: "insights.coach.history.groupYesterday" }, - { id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" }, - { id: "earlier", labelKey: "insights.coach.history.groupEarlier" }, - ] as const - ) - .map((g) => ({ ...g, conversations: groups[g.id] })) - .filter((g) => g.conversations.length > 0); -} - function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { const { t, locale } = useTranslations(); const [openId, setOpenId] = useState(null); @@ -139,7 +87,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { ); const isSearching = debouncedFilter.trim().length > 0; - const groups = useMemo(() => groupByRecency(visible), [visible]); + const groups = useMemo(() => groupConversationsByRecency(visible), [visible]); // Same callback-ref pattern as the rail: a `useState` setter (not a plain // `useRef`) so the sentinel hook's `root` re-fires its effect once the diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index 6ac9fbfc9..f22b463d5 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -81,6 +81,13 @@ import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; * this is a prefill, not an auto-send. * Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope * only ever applies to a fresh conversation's first turn. + * + * The conversations panel on the right of the thread carries the history, + * New chat and the settings gear (the composer's `+` menu is gone). Picking a + * conversation or starting a new one rewrites `?c=` in place, so a reload + * stays on the open thread. `?settings=data` (the link from Settings → Coach) + * opens the gear on "What I can see"; the param is consumed on mount and + * dropped from the URL so a reload does not reopen the overlay. */ function CoachPageBody() { const searchParams = useSearchParams(); @@ -118,6 +125,21 @@ function CoachPageBody() { : null; const seedPrefill = freshChat ? searchParams.get("ask") : null; + // Read once: the panel consumes it on mount, then the URL loses it. + const [openSettingsOnData] = useState( + () => searchParams.get("settings") === "data", + ); + useEffect(() => { + if (!openSettingsOnData) return; + const url = new URL(window.location.href); + url.searchParams.delete("settings"); + window.history.replaceState( + window.history.state, + "", + url.pathname + url.search, + ); + }, [openSettingsOnData]); + // #781 — a navigation away mid-reply recorded the interrupted conversation // (see `COACH_INTERRUPTED_STORAGE_KEY` in `use-coach.ts`). Returning to the // Coach reopens that thread — where the persisted `cancelled` marker shows @@ -200,6 +222,7 @@ function CoachPageBody() { // unread coach-initiated exception and the interrupted first-turn // resume (#781, whose conversation id never reached the client). autoOpenMostRecent={hasUnreadCoachMessage || resumeInterruptedLatest} + openSettingsOnData={openSettingsOnData} /> ); } diff --git a/src/app/globals.css b/src/app/globals.css index 3e1e61a6d..b00af340d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1054,3 +1054,20 @@ scroll-behavior: auto !important; } } + +/* ── Toasts clear the Coach's New chat button ──────────────────────── + * The Coach's conversations panel keeps its round New chat button in the + * bottom-right corner, which is where every toast lands. While the panel is + * open (docked or as a sheet), bottom toasts rise by the button's footprint + * (48 px plus its 16 px inset) so a delete's Undo never covers it. The + * selectors outrank sonner's own `bottom` rules, phone width included. */ +:root:has([data-slot="coach-conversations-panel"][data-state="open"]) + [data-sonner-toaster][data-y-position="bottom"] { + bottom: calc(var(--offset-bottom) + 4rem); +} +@media (max-width: 600px) { + :root:has([data-slot="coach-conversations-panel"][data-state="open"]) + [data-sonner-toaster][data-y-position="bottom"] { + bottom: calc(var(--mobile-offset-bottom) + 4rem); + } +} diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index f8159ff8c..8413cb0e3 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -355,6 +355,9 @@ const DIGEST: DailyDigest = { score: null, topSignal: null, briefingLead: "A steady week so far.", + lead: { text: "A steady week so far.", source: "briefing" }, + today: [], + restMode: null, line: "A steady week so far.", justIn: null, reactionLine: null, diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx index d22129481..fed5f99e9 100644 --- a/src/components/daily/__tests__/today-hero-just-in.test.tsx +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -42,6 +42,9 @@ function digest(over: Partial = {}): DailyDigest { score: { value: 82, band: "green", delta: 3 }, topSignal: null, briefingLead: "Your week is trending steady.", + lead: { text: "Your week is trending steady.", source: "briefing" }, + today: [], + restMode: null, line: "Your week is trending steady.", worthALook: [], justIn: null, @@ -105,6 +108,7 @@ describe("TodayHero — an arrival is not a chip", () => { digest={digest({ score: null, briefingLead: null, + lead: null, worthALook: [], reactionLine: null, justIn: { kind: "sleep_night", at: ARRIVED_AT }, @@ -152,6 +156,7 @@ describe("TodayHero — what earns the hero without a score", () => { return digest({ score: null, briefingLead: null, + lead: null, reactionLine: null, phase: "provisional", sleepPending: true, @@ -203,6 +208,8 @@ describe("TodayHero — the reaction line replaces the lead", () => { digest={digest({ reactionLine: REACTION, briefingLead: "Your week is trending steady.", + // The server resolved the reaction line as the lead. + lead: { text: REACTION, source: "reaction" }, })} />, ); @@ -218,6 +225,8 @@ describe("TodayHero — the reaction line replaces the lead", () => { digest={digest({ reactionLine: REACTION, briefingLead: "Your week is trending steady.", + // The server resolved the reaction line as the lead. + lead: { text: REACTION, source: "reaction" }, })} />, ); @@ -246,6 +255,7 @@ describe("TodayHero — the reaction line replaces the lead", () => { digest={digest({ score: null, briefingLead: null, + lead: { text: REACTION, source: "reaction" }, worthALook: [], reactionLine: REACTION, justIn: { kind: "weight", at: ARRIVED_AT }, diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 5c8842fb1..6e6810c40 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -6,6 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context"; import { TodayHero } from "../today-hero"; import type { DailyDigest } from "@/lib/daily/digest"; import type { PriorityItem } from "@/lib/daily/priority-item"; +import type { TodayFact } from "@/lib/daily/today-overview"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; @@ -71,6 +72,9 @@ function digest(over: Partial = {}): DailyDigest { delta: "+6 mmHg vs your 30-day average", }, briefingLead: "Your week is trending steady.", + lead: { text: "Your week is trending steady.", source: "briefing" }, + today: [], + restMode: null, line: "Your week is trending steady.", worthALook: [doseItem, syncItem], justIn: null, @@ -146,6 +150,7 @@ describe("", () => { sleepPending: true, score: null, briefingLead: null, + lead: null, line: "Your health score today is 82.", worthALook: [doseItem], })} @@ -160,13 +165,15 @@ describe("", () => { expect(html).not.toContain('data-slot="today-hero-briefing-link"'); }); - it("keeps the useful loaded narrative without repeating the ring's numeric score", () => { + it("renders the server-resolved lead without repeating the ring's numeric score", () => { + // The server already dropped the score sentence from the briefing (see + // `today-overview.test.ts`); the hero prints what it was handed. const html = render( , ); @@ -178,12 +185,47 @@ describe("", () => { expect(html).toContain('data-slot="today-hero-lead"'); }); - it("promotes the top signal when score de-duplication removes the only lead", () => { + it("falls back to the stored lines for a digest cached before the lead existed", () => { + // The service worker's offline cache can hand back a digest written by + // the previous version, which carries no `lead` field at all. + const cached = digest({ reactionLine: "The new reading fits your week." }); + delete (cached as Partial).lead; + const html = render(); + expect(visibleText(html)).toContain("The new reading fits your week."); + + const briefingOnly = digest(); + delete (briefingOnly as Partial).lead; + expect(visibleText(render())).toContain( + "Your week is trending steady.", + ); + }); + + it("does not resurrect a line the server left out of the lead", () => { + // A current digest with `lead: null` decided there is nothing to say; + // its reaction line (e.g. one that only repeated the score) stays out. const html = render( , + ); + expect(visibleText(html)).not.toContain("Score 82 today."); + }); + + it("does not repeat the top signal under a lead made from it", () => { + // A briefing whose only sentence repeated the score leads with the top + // signal's headline (resolved on the server); the muted signal line under + // it would say the same thing twice. + const html = render( + ", () => { sleepPending: true, score: null, briefingLead: null, + lead: null, reactionLine: null, line: "Your health score today is 82.", worthALook: [doseItem], @@ -228,6 +271,7 @@ describe("", () => { score: null, topSignal: null, briefingLead: null, + lead: null, line: "Nothing needs your attention today — everything's tracking normally.", worthALook: [], })} @@ -246,6 +290,7 @@ describe("", () => { score: null, topSignal: null, briefingLead: null, + lead: null, line: "Nothing needs your attention today — everything's tracking normally.", worthALook: [], reactionLine: null, @@ -273,7 +318,7 @@ describe("", () => { expect(html).toContain('data-slot="today-hero-score"'); }); - it("shows the first-class all-clear line when a score is present but nothing is notable", () => { + it("draws no empty rail and no all-clear sentence once a lead carries the day", () => { const html = render( ", () => { />, ); expect(html).toContain('data-slot="today-hero"'); - expect(html).toContain('data-slot="today-hero-all-clear"'); + expect(html).toContain('data-slot="today-hero-lead"'); + expect(html).not.toContain('data-slot="today-hero-all-clear"'); expect(html).not.toContain('data-slot="today-hero-rail"'); }); + it("keeps only the delta when the lead already says the headline", () => { + const html = render( + , + ); + const text = visibleText(html); + expect(html).toContain('data-slot="today-hero-signal"'); + expect(text).toContain("+6 mmHg vs your 30-day average"); + // Said once, by the lead; the signal line carries only the delta. + expect(text.split("touch high this morning").length - 1).toBe(1); + }); + it("uses the compact score-only composition when all-clear has no narrative", () => { const html = render( ", () => { score: null, topSignal: null, briefingLead: null, + lead: null, worthALook: [], })} primaryContent="reminders" @@ -446,3 +512,172 @@ describe(" score basis", () => { expect(html).not.toContain('data-slot="today-hero-score-basis"'); }); }); + +const FACTS: TodayFact[] = [ + { + kind: "rest_mode", + label: "Rest mode", + value: "Day 3", + href: "/illness", + moduleKey: "illness", + }, + { + kind: "medications", + label: "Medications", + value: "1 of 3 taken", + href: "/medications", + moduleKey: "medications", + }, + { + kind: "appointment", + label: "Appointment", + value: "Tomorrow 09:30, Dr. Example", + href: "/checkups", + }, + { + kind: "sleep", + label: "Last night", + value: "7h 20m, close to your usual", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "cycle", + label: "Cycle", + value: "Follicular, day 12", + href: "/cycle", + moduleKey: "cycle", + }, +]; + +describe(" Today overview", () => { + it("renders each fact as a link to its page, in the order it was given", () => { + const html = render(); + expect(html).toContain('data-slot="today-hero-today"'); + const kinds = [ + ...html.matchAll(/data-slot="today-hero-fact" data-kind="([a-z_]+)"/g), + ].map((m) => m[1]); + expect(kinds).toEqual([ + "rest_mode", + "medications", + "appointment", + "sleep", + "cycle", + ]); + for (const fact of FACTS) { + expect(html).toContain(`href="${fact.href}"`); + expect(visibleText(html)).toContain(fact.value); + } + }); + + it("hides the fifth fact below md and never the first four", () => { + const html = render(); + const items = [ + ...html.matchAll(/]*data-slot="today-hero-fact"[^>]*>/g), + ].map((m) => m[0]); + expect(items).toHaveLength(5); + items.slice(0, 4).forEach((li) => expect(li).not.toContain("hidden")); + expect(items[4]).toContain("hidden md:block"); + }); + + it("drops the all-clear sentence when facts carry the day", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="today-hero-all-clear"'); + expect(html).toContain('data-layout="narrative"'); + }); + + it("keeps the hero for an account whose only content is facts", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain('data-slot="today-hero-today"'); + }); + + it("renders a deterministic lead without the AI signal line", () => { + const html = render( + , + ); + expect(html).toContain('data-source="signal"'); + expect(html).not.toContain('data-slot="today-hero-signal"'); + }); + + it("shows the facts in reminders mode, without the ring", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero-today"'); + expect(html).not.toContain('data-slot="today-hero-score"'); + }); +}); + +describe(" steady line", () => { + const steady = (over: Partial>) => + digest({ + score: { + value: 94, + band: "green", + delta: null, + deltaReason: "below_noise_floor", + scoreVersion: SCORE_VERSION, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + steadyWeeks: 4, + ...over, + }, + }); + + it("says how long the score has held when no delta is shown", () => { + const html = render(); + expect(html).toContain('data-slot="today-hero-score-steady"'); + expect(visibleText(html)).toContain("Steady for 4 weeks"); + expect(html).not.toContain('data-slot="today-hero-score-delta"'); + }); + + it("never pairs a steady line with a delta", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="today-hero-score-delta"'); + expect(html).not.toContain('data-slot="today-hero-score-steady"'); + }); + + it("says nothing without a run", () => { + const html = render(); + expect(html).not.toContain('data-slot="today-hero-score-steady"'); + }); + + it("says at least when the run reaches past what was read", () => { + const html = render( + , + ); + expect(visibleText(html)).toContain("Steady for at least 17 weeks"); + }); + + it("uses the German plural", () => { + const html = render(, "de"); + expect(visibleText(html)).toContain("Seit 4 Wochen stabil"); + }); +}); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 47389ec15..1eb83b7c3 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -54,6 +54,8 @@ import { usePriorityItemDismiss } from "@/hooks/use-priority-item-dismiss"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; +import { pluralKey } from "@/lib/i18n/plural"; +import { MAX_TODAY_FACTS_NARROW as TODAY_FACTS_NARROW } from "@/lib/daily/today-overview"; import type { DailyDigest } from "@/lib/daily/digest"; import type { HeroPrimaryContent } from "@/lib/dashboard-layout"; import { @@ -72,26 +74,18 @@ function formatDelta( return t("daily.today.deltaVsBaseline", { delta: signed }); } -/** - * The ring already owns the headline number. AI briefing copy can still carry - * an older lead sentence that repeats that exact score, so remove only the - * sentence containing the displayed number and retain every other useful - * sentence verbatim. If the score sentence was the whole lead, render no lead - * rather than inventing replacement prose. - */ -function withoutRepeatedScore( - text: string | null, - score: number | null, -): string | null { - if (!text || score === null) return text; - const shownScore = String(Math.round(score)); - const scorePattern = new RegExp( - `(^|[^0-9])${shownScore.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}([^0-9]|$)`, - ); - const sentences = text.match(/[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g) ?? [text]; - const retained = sentences.filter((sentence) => !scorePattern.test(sentence)); - const result = retained.join(" ").replace(/\s+/g, " ").trim(); - return result.length > 0 ? result : null; +/** Lowercase letters and digits only, for a wording-insensitive compare. */ +function normalise(text: string): string { + return text + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +/** Whether `lead` already says what `headline` says. */ +function restates(lead: string, headline: string): boolean { + const h = normalise(headline); + return h.length > 0 && normalise(lead).includes(h); } export function TodayHero({ @@ -104,7 +98,7 @@ export function TodayHero({ /** Server-persisted hero choice from the dashboard layout blob. */ primaryContent?: HeroPrimaryContent; }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); // The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element // and paints from the server-dehydrated digest on both the SSR and the // hydration render), so nothing in it may differ between those two passes. @@ -138,35 +132,61 @@ export function TodayHero({ const hasScore = digest.score !== null; const hasItems = digest.worthALook.length > 0; - // Lead precedence, warmest first: the day's reaction line REPLACES the - // briefing lead once something landed — it never appends as a second - // paragraph, so the hero stays exactly one lead line tall and nothing below - // it shifts. The briefing lead is the standing read; the deterministic - // `line` is the floor a keyless self-hoster still gets. - const rawLead = - digest.reactionLine ?? - digest.briefingLead ?? - // The deterministic line is a useful floor only once the ring has a real - // score. A provisional/null ring must not pair with a fabricated score - // sentence from that fallback. - (hasScore ? digest.line : null); - const lead = withoutRepeatedScore(rawLead, digest.score?.value ?? null); + // `?? []` / `?? null` below: the service worker's offline data cache can + // still hand back a digest written before the overview fields existed, and + // a missing list must read as "no facts", not as a crash. + const facts = digest.today ?? []; + const hasFacts = facts.length > 0; + // The lead is resolved on the server (`digest.lead`): the reaction line or + // a briefing sentence with content while AI text is available, otherwise a + // deterministic sentence about the day's strongest signal. Greetings and a + // sentence that only repeats the ring's number never reach it, so the hero + // renders it verbatim and decides nothing about it. A digest the service + // worker cached before the field existed has no `lead` at all; it falls + // back to the lines that version led with. + const lead = + digest.lead === undefined + ? (digest.reactionLine ?? digest.briefingLead ?? null) + : (digest.lead?.text ?? null); const topSignal = digest.topSignal; - // A score-only briefing can legitimately lose its lead when the only - // sentence repeated the number already printed inside the ring. Keep the - // day's signal as the visible headline in that case instead of leaving the - // hero with a muted, orphaned subtitle below an empty bold slot. - const signalFallbackLead = - hasScore && !lead ? topSignal?.headline?.trim() || null : null; - const displayLead = lead ?? signalFallbackLead; - // A score-only all-clear digest has no narrative content for the leading - // column. Keeping the full md ring in that two-column shell left a blank - // column beside a 168 px dial and pushed the actual all-clear read below it. - // Treat that honest fallback as its own compact composition: the all-clear - // copy occupies the leading column and a smaller version of the SAME score - // ring remains the one numeric face. This branch depends only on the - // server-delivered digest, so SSR and hydration choose it identically. - const compactAllClear = !lead && !topSignal && !hasItems; + // The briefing's top signal rides under an AI lead as its supporting line, + // and only with what the lead does not already say: a headline the lead + // restates is dropped and its delta, when there is one, stands alone. + const signalLine = + topSignal && lead && digest.lead?.source !== "signal" + ? restates(lead, topSignal.headline) + ? topSignal.delta + ? { headline: null, delta: topSignal.delta } + : null + : { headline: topSignal.headline, delta: topSignal.delta } + : null; + // A score-only digest with nothing else to say has no narrative content for + // the leading column. Keeping the full md ring in that two-column shell left + // a blank column beside a 168 px dial and pushed the all-clear read below + // it, so that case keeps its compact composition: the all-clear copy leads + // and a smaller version of the SAME score ring stays the one numeric face. + // This branch depends only on the server-delivered digest, so SSR and + // hydration choose it identically. + const compactAllClear = !lead && !signalLine && !hasItems && !hasFacts; + + // How long the number has held, read off the stored daily scores on the + // server. Said only when no delta is: a moved score is described by its + // delta, a still one by its duration, never both under one ring. + const deltaShown = + digest.score !== null && + digest.score.delta !== null && + digest.score.deltaReason === null; + const steadyWeeks = digest.score?.steadyWeeks ?? null; + const steadyLine = + !deltaShown && steadyWeeks !== null + ? t( + // A run that reaches past what was read has no known start. + digest.score?.steadyAtLeast + ? pluralKey("daily.today.steadyAtLeastWeeks", steadyWeeks, locale) + : pluralKey("daily.today.steadyWeeks", steadyWeeks, locale), + { count: steadyWeeks }, + ) + : null; // v1.38 — what the ring's number rests on, when that is less than the // breadth the score recommends. The hero renders the number and nothing @@ -203,13 +223,7 @@ export function TodayHero({ // // A filtered layout is the one explicit exception: its empty digest means // "the hidden canonical candidates are quiet here", so retain all-clear. - if ( - !renderFilteredAllClear && - !hasScore && - !hasItems && - !digest.briefingLead && - !digest.reactionLine - ) { + if (!renderFilteredAllClear && !hasScore && !hasItems && !hasFacts && !lead) { return null; } @@ -252,6 +266,45 @@ export function TodayHero({ ) : null; + /* The Today overview — up to five statements about the day, resolved on the + server in priority order. A phone shows the first four: the fifth is + hidden below `md` rather than dropped, so the list a wide screen reads is + the same list, one line longer. Each line opens the page it belongs to. + The block never asks for anything; the rail below is the one place on the + hero that may. */ + const todayBlock = hasFacts ? ( +
+

+ {t("daily.today.heading")} +

+
    + {facts.map((fact, i) => ( +
  • = TODAY_FACTS_NARROW && "hidden md:block", + )} + > + + + {fact.label} + + + {fact.value} + + +
  • + ))} +
+
+ ) : null; + const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: // `.today-hero-wash` leans a faint `--primary` mix over the theme @@ -275,14 +328,16 @@ export function TodayHero({ className={heroShellClassName} >
- {rail ?? ( -

- {t("daily.today.allClear")} -

- )} + {todayBlock} + {rail ?? + (hasFacts ? null : ( +

+ {t("daily.today.allClear")} +

+ ))} {sleepPendingNote}
@@ -312,33 +367,43 @@ export function TodayHero({ : "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. */} +
{/* Hero numeric face: the read leads large in the foreground token, calm and legible — the day's read, not a slogan. */} - {displayLead ? ( -
- + {lead || signalLine ? ( +
+ {lead ? ( +
+ +
+ ) : null} + {/* Top signal — present-tense headline + optional delta, one + muted step down so it supports the lead without + competing. */} + {signalLine ? ( +

+ {signalLine.headline} + {signalLine.headline && signalLine.delta ? " · " : null} + {signalLine.delta} +

+ ) : null}
) : null} - {/* Top signal — present-tense headline + optional delta, one - muted step down so it supports the lead without competing. */} - {topSignal && lead ? ( -

- {topSignal.headline} - {topSignal.delta ? ( - - {" "} - · {topSignal.delta} - - ) : null} -

- ) : null} + {todayBlock} {compactAllClear ? (

- {digest.score && - digest.score.delta !== null && - digest.score.deltaReason === null ? ( + {deltaShown && digest.score?.delta != null ? ( ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} {basisLine ? ( + {}} + searchInputRef={{ current: null }} + activeKinds={new Set()} + onToggleKind={() => {}} + conditionChips={[{ episodeId: "ep-1", name: "Knee" }]} + activeEpisodeId={undefined} + onToggleEpisode={() => {}} + procedureChips={procedures} + activeEncounterId={activeEncounterId} + onToggleEncounter={() => {}} + years={[2025]} + activeYear={undefined} + onToggleYear={() => {}} + activeCount={activeCount} + onClearAll={() => {}} + /> + , + ); +} + +/** The opening tag of the element carrying `data-slot=""`. */ +function triggerTag(html: string, slot: string): string { + const at = html.indexOf(`data-slot="${slot}"`); + expect(at).toBeGreaterThan(-1); + return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1); +} + +describe(" procedure facet", () => { + it("renders no procedure control when nothing is filed against a procedure", () => { + const html = render({ procedures: [] }); + expect(html).not.toContain('data-slot="document-procedure-filter"'); + // The positive control: the neighbouring facets still render. + expect(html).toContain('data-slot="document-condition-filter"'); + expect(html).toContain('data-slot="document-year-filter"'); + }); + + it("sits between the condition and the year facet on the one row", () => { + const html = render(); + const condition = html.indexOf('data-slot="document-condition-filter"'); + const procedure = html.indexOf('data-slot="document-procedure-filter"'); + const year = html.indexOf('data-slot="document-year-filter"'); + expect(condition).toBeGreaterThan(-1); + expect(procedure).toBeGreaterThan(condition); + expect(year).toBeGreaterThan(procedure); + expect(html).toContain("flex flex-nowrap items-center gap-2"); + }); + + it("is an icon-only trigger on a phone while inactive, named for the facet", () => { + const html = render(); + const tag = triggerTag(html, "document-procedure-filter"); + expect(tag).toContain('aria-label="All procedures"'); + expect(tag).not.toContain("border-primary/40"); + // Same trigger chrome as the condition facet: the label hides below `sm`. + const conditionTag = triggerTag(html, "document-condition-filter"); + expect(tag.match(/class="([^"]*)"/)?.[1]).toBe( + conditionTag.match(/class="([^"]*)"/)?.[1], + ); + expect(html).toContain( + '', + ); + }); + + it("shows the picked procedure's heading, tinted, and visible on a phone", () => { + const html = render({ activeEncounterId: "enc-knee", activeCount: 1 }); + const tag = triggerTag(html, "document-procedure-filter"); + expect(tag).toContain('aria-label="Knee arthroscopy"'); + expect(tag).toContain("border-primary/40 bg-primary/10 text-foreground"); + expect(html).toContain( + 'Knee arthroscopy', + ); + // The one clear control appears for it like for any other facet. + expect(html).toContain('aria-label="Clear filters"'); + }); + + it("leaves the trigger at rest for a filtered visit that is not a choice", () => { + // A routine visit reached from its own row filters the list but is not a + // procedure: the facet must not claim it. + const html = render({ activeEncounterId: "enc-routine", activeCount: 1 }); + const tag = triggerTag(html, "document-procedure-filter"); + expect(tag).toContain('aria-label="All procedures"'); + expect(tag).not.toContain("border-primary/40"); + }); +}); diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index fff5fc7bf..ad9ddb2e9 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { + buildProcedureChoices, buildTimelineItems, buildVaultListApiSearch, classifyUploadFailure, @@ -457,3 +458,63 @@ describe("documentCardKeyAction", () => { } }); }); + +describe("buildProcedureChoices", () => { + const labels = { + kindName: "Procedure or surgery", + siteText: (site: string | null, side: string | null) => + site ? (side ? `${site} (${side.toLowerCase()})` : site) : null, + formatDate: (iso: string) => iso.slice(0, 10), + }; + const knee = { + encounterId: "enc-knee", + occurredAt: "2025-10-02T08:00:00.000Z", + reason: "Knee arthroscopy", + bodySite: "Knee", + laterality: "LEFT" as const, + practitionerName: "Day clinic", + }; + + it("heads each choice like the visits page: reason, else practice, else kind", () => { + const choices = buildProcedureChoices( + [ + knee, + { ...knee, encounterId: "enc-2", reason: null, bodySite: null }, + { + ...knee, + encounterId: "enc-3", + reason: " ", + practitionerName: null, + laterality: null, + }, + ], + null, + labels, + ); + expect(choices).toEqual([ + { + encounterId: "enc-knee", + name: "Knee arthroscopy", + detail: "Knee (left) · 2025-10-02", + }, + { encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" }, + { + encounterId: "enc-3", + name: "Procedure or surgery", + detail: "Knee · 2025-10-02", + }, + ]); + }); + + it("keeps an active procedure the usage list no longer holds, once", () => { + const gone = { ...knee, encounterId: "enc-gone", reason: "Appendectomy" }; + const choices = buildProcedureChoices([knee], gone, labels); + expect(choices.map((c) => c.encounterId)).toEqual(["enc-knee", "enc-gone"]); + // Already listed: not added a second time. + expect(buildProcedureChoices([knee], knee, labels)).toHaveLength(1); + }); + + it("offers nothing when no procedure carries a document", () => { + expect(buildProcedureChoices([], null, labels)).toEqual([]); + }); +}); diff --git a/src/components/documents/document-filter-bar.tsx b/src/components/documents/document-filter-bar.tsx index 9febb0244..8ec1ba787 100644 --- a/src/components/documents/document-filter-bar.tsx +++ b/src/components/documents/document-filter-bar.tsx @@ -1,11 +1,12 @@ "use client"; /** - * The vault's sticky filter rail: debounced title/filename search plus three + * The vault's sticky filter rail: debounced title/filename search plus four * compact dropdown facets — type (multi-select, OR inside the facet), - * condition (episodes that actually carry links, single-select), and year - * (years present in the corpus, single-select) — and a one-tap clear with - * the active-facet count. + * condition (episodes that actually carry links, single-select), procedure + * (procedures and surgeries that actually carry links, single-select), and + * year (years present in the corpus, single-select) — and a one-tap clear + * with the active-facet count. * * The controls sit on ONE row at every width: the search flexes and can * shrink to nothing while each dropdown trigger stays a fixed compact @@ -24,6 +25,7 @@ import { ChevronDown, ListFilter, ScanSearch, + Scissors, Search, X, } from "lucide-react"; @@ -43,14 +45,26 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta"; - -/** Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. */ -const TRIGGER_ACTIVE = "border-primary/40 bg-primary/10 text-foreground"; -/** Shared trigger chrome: compact, fixed, never shrinks below its label. */ -const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal"; +import type { ProcedureFilterChoice } from "./vault-utils"; /** - * The facet label is the whole point of the trigger — but three text labels + * Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. + * An active trigger may also shrink (its label truncates further), so on a + * phone with several facets set the row gives up label width before it gives + * up the search field. + */ +const TRIGGER_ACTIVE = + "border-primary/40 bg-primary/10 text-foreground min-w-0 shrink"; +/** + * Shared trigger chrome: compact, fixed, never shrinks below its label. The + * height matches the search field beside it (44 px on a phone, the tap-target + * floor, 40 px from `sm`), so the row reads as one line of controls. + */ +const TRIGGER_CLASSES = + "h-11 min-w-11 shrink-0 gap-1.5 font-normal sm:h-10 sm:min-w-0"; + +/** + * The facet label is the whole point of the trigger — but four text labels * plus the search will not fit a 360px phone. So the label shows from `sm` * up, and on a phone only when the facet is ACTIVE (truncated), while the * leading icon always carries the facet identity. The trailing chevron is @@ -59,7 +73,10 @@ const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal"; * pinned via `aria-label` so an icon-only trigger still announces its state. */ function facetLabelClass(active: boolean): string { - return cn("max-w-28 truncate", active ? "inline" : "hidden sm:inline"); + return cn( + "max-w-28 min-w-0 truncate", + active ? "inline" : "hidden sm:inline", + ); } export interface ConditionChip { @@ -76,6 +93,9 @@ export function DocumentFilterBar({ conditionChips, activeEpisodeId, onToggleEpisode, + procedureChips, + activeEncounterId, + onToggleEncounter, years, activeYear, onToggleYear, @@ -94,6 +114,9 @@ export function DocumentFilterBar({ conditionChips: ConditionChip[]; activeEpisodeId: string | undefined; onToggleEpisode: (episodeId: string) => void; + procedureChips: ProcedureFilterChoice[]; + activeEncounterId: string | undefined; + onToggleEncounter: (encounterId: string) => void; years: number[]; activeYear: number | undefined; onToggleYear: (year: number) => void; @@ -120,6 +143,12 @@ export function DocumentFilterBar({ const conditionLabel = activeCondition?.name ?? t("documents.filter.conditionAll"); + const activeProcedure = procedureChips.find( + (chip) => chip.encounterId === activeEncounterId, + ); + const procedureLabel = + activeProcedure?.name ?? t("documents.filter.procedureAll"); + const yearLabel = activeYear !== undefined ? String(activeYear) @@ -132,10 +161,12 @@ export function DocumentFilterBar({ > {/* One row at every width: the search flexes (min-w-0 → it can shrink to nothing), every dropdown trigger stays a fixed compact control, and - the clear pins to the trailing edge. `flex-nowrap` guarantees the bar + the clear pins to the trailing edge. The search keeps at least its + icon plus its padding (the `/` hint and its padding are desktop + only), so it never bleeds under the next control. `flex-nowrap` guarantees the bar never breaks to a second line. */}
-
+
onSearchChange(e.target.value)} placeholder={t("documents.filter.searchPlaceholder")} aria-label={t("documents.filter.searchLabel")} - className="pr-8 pl-9" + // On a phone the field can shrink to its icon; the browser's own + // clear button would then sit on top of the icon, so it goes and + // the row's clear control does the job. + className="min-w-0 pr-3 pl-9 sm:pr-8 max-sm:[&::-webkit-search-cancel-button]:appearance-none" aria-keyshortcuts="/" /> ) : null} + {/* Procedure — single-select; only rendered when procedures carry + links. Same cadence as the condition facet beside it. */} + {procedureChips.length > 0 ? ( + + + + + + + {t("documents.filter.procedureGroup")} + + + {procedureChips.map((chip) => ( + onToggleEncounter(chip.encounterId)} + > + + {chip.name} + + {chip.detail} + + + + ))} + + + ) : null} + {/* Year — single-select; only rendered when the corpus spans years. */} {years.length > 0 ? ( @@ -306,7 +392,7 @@ export function DocumentFilterBar({ size="sm" onClick={onClearAll} aria-label={t("documents.filter.clear")} - className="text-muted-foreground shrink-0" + className="text-muted-foreground h-11 min-w-11 shrink-0 sm:h-10 sm:min-w-0" > diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index f35c9c7c3..bc4ad5be6 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -3,7 +3,7 @@ /** * The Dokumente vault — one wide, calm, filing-cabinet-fast surface. * - * Filter state lives in the URL (`?q&kind&episode&year`) so every view is + * Filter state lives in the URL (`?q&kind&episode&encounter&year`) so every view is * shareable, back-button-safe, and deep-linkable from the illness page and * labs. Search debounces 200 ms and `/` focuses it. The timeline below is * virtualized; uploads appear optimistically above it in < 100 ms. @@ -44,7 +44,10 @@ import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; -import { encounterKindText } from "@/components/encounters/encounter-labels"; +import { + bodySiteText, + encounterKindText, +} from "@/components/encounters/encounter-labels"; import { useEncounters } from "@/hooks/use-encounters"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { EncounterKind } from "@/generated/prisma/client"; @@ -73,12 +76,14 @@ import { useReindexAll } from "./use-content-index"; import { useDocumentUpload } from "./use-document-upload"; import { usePageFileDrop } from "./use-page-file-drop"; import { + buildProcedureChoices, buildVaultListApiSearch, countActiveFilters, documentDateKey, expandRangeSelection, hasProcessingDocument, parseVaultSearchParams, + type ProcedureFilterChoice, resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, @@ -345,6 +350,17 @@ export function DocumentsView() { ); }; + const toggleEncounter = (encounterId: string) => { + applyFilters( + { + ...filters, + encounterId: + filters.encounterId === encounterId ? undefined : encounterId, + }, + "push", + ); + }; + const toggleYear = (year: number) => { applyFilters( { ...filters, year: filters.year === year ? undefined : year }, @@ -418,15 +434,19 @@ export function DocumentsView() { enqueueRef.current = upload.enqueue; }, [upload.enqueue]); const episodeIdFilter = filters.episodeId; + const encounterIdFilter = filters.encounterId; const enqueueFiles = useCallback( (files: File[]) => { if (files.length === 0) return; - enqueueRef.current(files, { episodeId: episodeIdFilter }); + enqueueRef.current(files, { + episodeId: episodeIdFilter, + encounterId: encounterIdFilter, + }); setUploadAnnouncement( t("documents.upload.queuedAnnouncement", { count: files.length }), ); }, - [episodeIdFilter, t], + [episodeIdFilter, encounterIdFilter, t], ); const { dropActive } = usePageFileDrop( canManageDocuments ? enqueueFiles : undefined, @@ -504,6 +524,42 @@ export function DocumentsView() { [visits.data, t, format], ); + // Procedure choices: every procedure carrying at least one live document + // link, served by the usage endpoint for the same reason as the condition + // chips, plus the actively filtered visit when it is a procedure (see + // `buildProcedureChoices`). + const procedureChips = useMemo(() => { + const activeVisit = filters.encounterId + ? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find( + (v) => v.id === filters.encounterId && v.kind === "PROCEDURE", + ) + : undefined; + return buildProcedureChoices( + usage.data?.linkedProcedures ?? [], + activeVisit + ? { + encounterId: activeVisit.id, + occurredAt: activeVisit.occurredAt, + reason: activeVisit.reason, + bodySite: activeVisit.bodySite, + laterality: activeVisit.laterality, + practitionerName: activeVisit.practitioner?.name ?? null, + } + : null, + { + kindName: encounterKindText(t, "PROCEDURE"), + siteText: (site, laterality) => bodySiteText(t, site, laterality), + formatDate: (iso) => format.date(iso), + }, + ); + }, [ + usage.data?.linkedProcedures, + filters.encounterId, + visits.data, + t, + format, + ]); + // Year segmenter: years present in the loaded corpus (+ the active year). const years = useMemo(() => { const set = new Set(); @@ -921,6 +977,9 @@ export function DocumentsView() { conditionChips={conditionChips} activeEpisodeId={filters.episodeId} onToggleEpisode={toggleEpisode} + procedureChips={procedureChips} + activeEncounterId={filters.encounterId} + onToggleEncounter={toggleEncounter} years={years} activeYear={filters.year} onToggleYear={toggleYear} diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts index d58919ef2..bd7a9e1c7 100644 --- a/src/components/documents/use-document-upload.ts +++ b/src/components/documents/use-document-upload.ts @@ -59,6 +59,11 @@ export interface UploadQueueItem { export interface EnqueueOptions { /** Pre-link every uploaded document to this episode (deep-link uploads). */ episodeId?: string; + /** + * Pre-link every uploaded document to this visit — the active procedure + * filter, so a file dropped into a filtered view stays in it. + */ + encounterId?: string; } interface UploadLimits { @@ -139,6 +144,7 @@ export function uploadViaXhr( const fd = new FormData(); fd.append("file", file); if (options.episodeId) fd.append("episodeIds", options.episodeId); + if (options.encounterId) fd.append("encounterIds", options.encounterId); xhr.send(fd); }); } diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 10cfabe53..2c363d9bf 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -10,6 +10,7 @@ import { DOCUMENT_LIST_DEFAULT_LIMIT, INBOUND_DOCUMENT_KINDS, + type DocumentLinkedProcedureDto, type InboundDocumentDto, type InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; @@ -455,3 +456,53 @@ export function documentCardKeyAction(key: string): DocumentCardKeyAction { if (key === "Delete") return "delete"; return null; } + +/** One procedure choice in the filter bar, labelled for the reader. */ +export interface ProcedureFilterChoice { + encounterId: string; + /** The heading the visits page shows for it. */ + name: string; + /** Body site and date, the line that tells two same-named ones apart. */ + detail: string; +} + +/** + * The procedure filter's choices: the usage read's linked procedures, plus the + * actively filtered visit when it is a procedure that list does not hold (its + * last link was just removed, or a deep link names it). A filtered visit that + * is not a procedure adds nothing — it is not a procedure choice. + * + * Each is labelled the way the visits page heads the row: the reason, else the + * practice, else the kind's name. The body site (with its side) and the date + * ride the second line. Order is the server's, newest first, with the + * appended active visit last. + */ +export function buildProcedureChoices( + linked: readonly DocumentLinkedProcedureDto[], + active: DocumentLinkedProcedureDto | null, + labels: { + kindName: string; + siteText: ( + bodySite: string | null, + laterality: DocumentLinkedProcedureDto["laterality"], + ) => string | null; + formatDate: (iso: string) => string; + }, +): ProcedureFilterChoice[] { + const sources = [...linked]; + if (active && !sources.some((p) => p.encounterId === active.encounterId)) { + sources.push(active); + } + return sources.map((procedure) => { + const site = labels.siteText(procedure.bodySite, procedure.laterality); + const date = labels.formatDate(procedure.occurredAt); + return { + encounterId: procedure.encounterId, + name: + procedure.reason?.trim() || + procedure.practitionerName?.trim() || + labels.kindName, + detail: site ? `${site} · ${date}` : date, + }; + }); +} diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index 3cd40e333..21626c678 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -158,16 +158,16 @@ describe(" page deep-link (#67)", () => { expect(html).toContain('data-slot="coach-hero"'); }); - // v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives in the - // composer's `+` actions menu. The page chrome is the composer alone, so the - // surface exposes the `+` actions trigger and no standalone toolbar gear. - it("drops the page-toolbar gear and exposes the composer + actions menu", () => { + // The page carries no toolbar and the composer no `+` menu: New chat, the + // conversation list and the settings gear live in the conversations panel. + // On the server the panel is closed (it docks only once the client knows + // the viewport), so the SSR markup holds the conversation column alone. + it("renders the conversation column with no toolbar and no composer menu", () => { const html = render(, makeClient()); - // The old page toolbar + its gear are gone. + expect(html).toContain('data-slot="coach-page-main"'); expect(html).not.toContain('data-slot="coach-page-settings"'); expect(html).not.toContain('data-slot="coach-page-toolbar"'); - // Settings now lives behind the composer's `+` actions menu (the menu - // content is portalled open-on-demand, so SSR shows only the trigger). - expect(html).toContain('data-slot="coach-input-actions"'); + expect(html).not.toContain('data-slot="coach-input-actions"'); + expect(html).not.toContain('href="/coach/conversations"'); }); }); diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 99e93bcea..d6f200bf4 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -229,10 +229,9 @@ describe("", () => { expect(handler).toHaveBeenCalledWith("typed"); }); - it("omits the control-hub action row by default (drawer composer)", () => { - // v1.18.11 (W11) — the hub is page-only; without `showHub` the composer - // stays the single-row drawer layout (mic + send on one baseline) and - // grows no actions menu or settings deep-link. + it("renders the single-row composer with no actions menu", () => { + // The composer carries text, attach and send only; it grows no actions + // menu or settings deep-link on either surface. const html = render( {}} onSubmit={() => {}} />, ); @@ -251,26 +250,25 @@ describe("", () => { value="" onChange={() => {}} onSubmit={() => {}} - showHub - onNewChat={() => {}} - onOpenHistory={() => {}} + attachEnabled + onPickFromVault={() => {}} + onUploadNew={() => {}} />, ); expect(html).toContain('data-slot="coach-input-controls"'); expect(html).toContain('data-slot="coach-input-leading"'); - expect(html).toContain('data-slot="coach-input-actions"'); - expect(html).not.toContain('data-slot="coach-input-settings"'); + expect(html).toContain('data-slot="coach-input-attach"'); expect(html).not.toContain('data-slot="coach-input-mic"'); expect(html).toContain('data-slot="coach-input-send"'); // DOM and visual order agree at every breakpoint: the textarea comes first, - // followed by the leading actions and then Send. + // followed by the leading attach control and then Send. const textareaIdx = html.indexOf('data-slot="coach-input-textarea"'); - const plusIdx = html.indexOf('data-slot="coach-input-actions"'); + const attachIdx = html.indexOf('data-slot="coach-input-attach"'); const sendIdx = html.indexOf('data-slot="coach-input-send"'); expect(textareaIdx).toBeGreaterThan(-1); - expect(textareaIdx).toBeLessThan(plusIdx); - expect(plusIdx).toBeLessThan(sendIdx); + expect(textareaIdx).toBeLessThan(attachIdx); + expect(attachIdx).toBeLessThan(sendIdx); const controls = html.match( /]*data-slot="coach-input-controls"[^>]*>/, @@ -295,22 +293,28 @@ describe("", () => { expect(controls?.[0]).toMatch(/\bself-end\b/); }); - it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => { + it("carries no conversation menu: New chat, Conversations and Settings live in the page's panel", () => { const html = render( {}} onSubmit={() => {}} - showHub - onNewChat={() => {}} - onOpenHistory={() => {}} + attachEnabled + onPickFromVault={() => {}} + onUploadNew={() => {}} />, ); - const actions = html.match( - /]*data-slot="coach-input-actions"[^>]*>/, + expect(html).not.toContain('data-slot="coach-input-actions"'); + expect(html).not.toContain("coach-input-action-new-chat"); + expect(html).not.toContain("coach-input-action-history"); + expect(html).not.toContain("coach-input-action-settings"); + expect(html).not.toContain('href="/settings/ai"'); + // The attach control stays, at the 44 px tap-target floor on phones. + const attach = html.match( + /]*data-slot="coach-input-attach"[^>]*>/, ); - expect(actions?.[0]).toMatch(/\bsize-11\b/); - expect(actions?.[0]).toMatch(/\bsm:size-9\b/); + expect(attach?.[0]).toMatch(/\bsize-11\b/); + expect(attach?.[0]).toMatch(/\bsm:size-9\b/); }); it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx new file mode 100644 index 000000000..2d4a8af6a --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import type { + ProviderChainData, + UserAIProvider, +} from "@/components/settings/ai/shared"; + +import { + chainWithFirst, + modelControlFor, + reasoningFieldFor, + selectableProviders, + switchDidNotTake, +} from "../coach-model-picker"; + +const CHAIN: ProviderChainData["configuredChain"] = [ + { providerType: "codex", enabled: true, available: false }, + { providerType: "openai", enabled: true, available: true }, + { providerType: "anthropic", enabled: false, available: true }, + { providerType: "local", enabled: true, available: true }, + { providerType: "admin-openai", enabled: true, available: true }, +]; + +function userProvider(over: Partial = {}): UserAIProvider { + return { + provider: "OPENAI", + model: "gpt-4o", + baseUrl: null, + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: true, + openaiKeyPreview: "sk-…abcd", + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + localReasoningEffort: null, + compatReasoningEffort: null, + ...over, + }; +} + +describe("chainWithFirst", () => { + it("moves the chosen provider to the front and keeps the rest in order", () => { + expect(chainWithFirst(CHAIN, "local")).toEqual([ + { providerType: "local", priority: 1, enabled: true }, + { providerType: "codex", priority: 2, enabled: true }, + { providerType: "openai", priority: 3, enabled: true }, + { providerType: "anthropic", priority: 4, enabled: false }, + { providerType: "admin-openai", priority: 5, enabled: true }, + ]); + }); + + it("never sends a reasoning setting, so the server keeps the stored one", () => { + for (const entry of chainWithFirst(CHAIN, "openai")) { + expect(Object.keys(entry).sort()).toEqual([ + "enabled", + "priority", + "providerType", + ]); + } + }); +}); + +describe("selectableProviders", () => { + it("offers only enabled providers that can answer", () => { + expect(selectableProviders(CHAIN)).toEqual([ + "openai", + "local", + "admin-openai", + ]); + }); +}); + +describe("switchDidNotTake", () => { + it("is quiet when the chosen provider leads the chain the server resolves", () => { + expect(switchDidNotTake("openai", { activeProvider: "openai" })).toBe( + false, + ); + }); + + it("speaks up when the server still answers with another provider", () => { + // A provider without a working key was saved to the front of the chain; + // the resolver skipped it and the select would jump back silently. + expect(switchDidNotTake("anthropic", { activeProvider: "openai" })).toBe( + true, + ); + expect(switchDidNotTake("anthropic", { activeProvider: null })).toBe(true); + }); +}); + +describe("modelControlFor", () => { + it("offers the presets for the provider whose key the person saved", () => { + const control = modelControlFor("openai", userProvider()); + expect(control.kind).toBe("preset"); + if (control.kind === "preset") expect(control.value).toBe("gpt-4o"); + }); + + it("does not offer a model for a provider the shared model column is not set up for", () => { + // The column belongs to OpenAI here; a Claude model name written for the + // Anthropic entry would be sent to OpenAI. + expect(modelControlFor("anthropic", userProvider()).kind).toBe("managed"); + }); + + it("keeps the operator's provider and Codex read-only", () => { + expect(modelControlFor("admin-openai", userProvider()).kind).toBe( + "managed", + ); + expect(modelControlFor("codex", userProvider()).kind).toBe("managed"); + expect(modelControlFor(null, userProvider()).kind).toBe("managed"); + }); + + it("gives the gateway its own free-text model", () => { + expect( + modelControlFor( + "openai-compatible", + userProvider({ compatModel: "qwen3:32b" }), + ), + ).toEqual({ kind: "gateway", value: "qwen3:32b" }); + }); +}); + +describe("reasoningFieldFor", () => { + it("writes the Local and gateway entries' own reasoning fields", () => { + expect( + reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })), + ).toEqual({ field: "localReasoningEffort", value: "low" }); + expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({ + field: "compatReasoningEffort", + value: null, + }); + }); + + it("has nothing for providers without a reasoning setting", () => { + expect(reasoningFieldFor("openai", userProvider())).toBeNull(); + expect(reasoningFieldFor("codex", userProvider())).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx new file mode 100644 index 000000000..bb595f401 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; +import type { + ProviderChainData, + UserAIProvider, +} from "@/components/settings/ai/shared"; + +import { CoachSettingsOverlay } from "../coach-settings-overlay"; +import { + COACH_SETTINGS_DATA_SECTION_ID, + CoachSettingsBody, +} from "../coach-settings-body"; + +const CHAIN: ProviderChainData = { + activeProvider: "local", + cachedActiveProvider: "local", + configuredChain: [ + { providerType: "codex", enabled: true, available: false }, + { providerType: "local", enabled: true, available: true }, + { providerType: "admin-openai", enabled: true, available: true }, + ], +}; + +const PROVIDER: UserAIProvider = { + provider: "LOCAL", + model: "qwen2.5", + baseUrl: "http://localhost:11434/v1", + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: null, + compatModel: null, + hasCompatKey: false, + responseTimeoutSeconds: null, + localReasoningEffort: "none", + compatReasoningEffort: null, +}; + +function render(node: React.ReactNode, chain: ProviderChainData = CHAIN) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(queryKeys.insightsProviderChain(), chain); + client.setQueryData(queryKeys.userAiProvider(), PROVIDER); + client.setQueryData(queryKeys.coachPrefs(), DEFAULT_COACH_PREFS); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("", () => { + it("holds the model choice, what the Coach sees, and a way to the full settings", () => { + const html = render(); + expect(html).toContain('data-slot="coach-settings-model"'); + expect(html).toContain('data-slot="coach-model-picker"'); + expect(html).toContain(`id="${COACH_SETTINGS_DATA_SECTION_ID}"`); + // "What I can see" is the existing rail, wired to the saved preference: + // the lookback control and the data-area switches. + expect(html).toContain('data-slot="coach-sources-window"'); + expect(html).toContain('data-slot="coach-sources-list"'); + expect(html).toMatch(/href="\/settings\/ai"[^>]*>All AI settings { + const html = render(); + const select = html.match( + /]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/, + )?.[0]; + expect(select).toBeDefined(); + expect(select).toContain('value="local"'); + expect(select).toContain('value="admin-openai"'); + expect(select).not.toContain('value="codex"'); + }); + + it("shows the operator's central Codex as the active provider when it answers", () => { + // `admin-codex` is never a chain entry, so no option matched it and the + // select showed its first option as the one answering. + const html = render(, { + ...CHAIN, + activeProvider: "admin-codex" as ProviderChainData["activeProvider"], + }); + const select = html.match( + /]*data-slot="coach-quick-provider"[\s\S]*?<\/select>/, + )?.[0]; + expect(select).toMatch( + /
- {/* v1.22 — per-message action row: Copy, then the timestamp - trailing. Muted until the bubble is hovered / focused on pointer - devices; always visible on touch (no hover to reveal it). */} -
- - {createdAt && } -
- {/* v1.16.8 — explicit remember control. Stating an allergy in - chat used to leave no durable trace unless a narrow - pattern pass happened to match the phrasing; this stores - the message into the editable self-context (Settings → AI) - on one tap, so it rides every future system prompt. Only - persisted messages get the control (an optimistic bubble - has no id yet), and only when the text fits the - self-context field cap. */} - {messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && ( - - )} + {/* One row under the question: copy, remember, time. Remember + stores the message in the self-context (Settings → AI); only a + persisted message that fits that field offers it. */} +
{avatarUrl ? ( // eslint-disable-next-line @next/next/no-img-element @@ -671,18 +535,7 @@ function ChatBubbleImpl({ ? t("insights.coach.errorProvider") : null; - const keyValues = metricSource?.keyValues ?? []; - // v1.12.0 — the provenance disclosure surfaces whenever there is any - // grounding to show: the source chips (metrics/windows) and/or the - // raw key-values. `SourceChips` itself returns null when the envelope - // carries neither metric nor window, so we mirror that condition here - // to decide whether the `
` shell renders at all. - const hasChips = - !!metricSource && - ((metricSource.metrics?.length ?? 0) > 0 || - (metricSource.windows?.length ?? 0) > 0); const method = metricSource?.method ?? null; - const hasEvidence = hasChips || keyValues.length > 0 || method !== null; // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- // grounded `metric:` chart under a SETTLED assistant turn. Skipped @@ -696,7 +549,6 @@ function ChatBubbleImpl({ .filter((meta) => meta.displayed) .map((meta) => meta.source.domain), ); - const hasResults = !inProgress && !errorCode && resultMetas.length > 0; const dataUsedCount = !inProgress && !errorCode ? countResultsInSection(resultMetas, "dataUsed") @@ -705,7 +557,7 @@ function ChatBubbleImpl({ !streaming && !inProgress && !errorCode && providerType !== "refusal" ? selectCoachChartTokens(content, metricSource?.metrics, shownDomains) : []; - const hasProvenance = hasEvidence || dataUsedCount > 0; + const settled = !inProgress && !errorCode && providerType !== "refusal"; // v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure // from this reply (each rewritten to the `[…]` elision mark). Shown only on a @@ -734,20 +586,36 @@ function ChatBubbleImpl({ )}
- {/* A reply with tables takes the column's full width, so a table or - chart is as wide as the thread allows instead of as wide as the - prose above it. */} + {/* The answer column. Below `sm` it takes the full width beside the + avatar (at 80 % the action row could not stay on one line at + 390 px); from `sm` it keeps the 80 % measure. It is always that + wide, whatever the answer's length, so the action row and the + chips never move sideways as a reply streams in; the prose bubble + inside shrink-wraps. A table or chart takes the column's width. */}
- {/* v1.39.4 — what the Coach read on this turn: live from the step - frames, restored from the provenance on reload. */} + {/* What the Coach read on this turn, live from the step frames and + restored from the provenance on reload; an older message lists + the areas it drew on. Opened, it also says how the answer was + worked out and holds the tables the answer read. */} 0 ? ( + + ) : null + } /> {/* v1.19.1 (C3) — the live turn shows the classic typing animation (three pulsing dots) while it is still thinking with no prose @@ -760,8 +628,9 @@ function ChatBubbleImpl({ it swaps to the streamed prose without a layout jump. */} {(content || safeError || inProgress) && (
)} - {/* v1.12.0 — collapse the whole provenance block ("what was - included") behind one disclosure, collapsed by default. The - source chips used to render fully expanded above the - evidence `
`, so the grounding context was often - taller than the answer itself. Folding the chips + the raw - key-values into a single closed disclosure keeps the reply - the focus and lets the user expand the grounding on demand — - and removes the always-on chip row that duplicated what the - disclosure already names. The disclosure surfaces whenever - there is any provenance to show (chips and/or key-values). */} - {hasProvenance && ( -
- setEvidenceOpen((e.target as HTMLDetailsElement).open) - } - // v1.4.27 F14 — always closed by default. The `open` - // attribute was previously tied to a per-user pref that - // surfaced raw values unconditionally; that pref is now - // retired and the disclosure is a true progressive- - // disclosure surface — the user clicks to expand. - // - // v1.4.27 MB3 / CF-32 — the `open` attribute is now - // controlled from local state so the summary's - // `aria-expanded` stays in lock-step. The native disclosure - // semantics (Enter / Space toggle) are preserved. - className={cn( - "border-border/50 bg-muted/30 group rounded-md border", - "px-2.5 py-1.5 text-xs", - )} - > - - -
- {/* v1.12.0 — the source chips now live inside the - disclosure so they expand with the rest of the - grounding instead of always painting above the - answer. */} - {hasChips && metricSource && ( - - )} - {keyValues.length > 0 && ( -
    - {keyValues.map((kv, idx) => ( -
  • - {/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic") - was rendered prefixed to every row, repeating - framing the disclosure heading already gives. - Drop the label and lead with the value; the - window stays as a parenthetical tail so the row - still answers "over what timeframe?". */} - - {kv.value} - {kv.unit ? ` ${kv.unit}` : ""} - - {kv.window && ( - - {" "} - ({kv.window}) - - )} -
  • - ))} -
- )} - {/* v1.39.4 — how the answer was worked out, beside the key - values it explains. */} - - {/* v1.39.4 — the tables the answer read but did not point at. */} - {dataUsedCount > 0 && ( - - )} -
-
- )} {/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action card. Live from the streaming hook, or restored from the persisted message provenance on reload. Not shown on @@ -978,42 +739,29 @@ function ChatBubbleImpl({ suggestedAction ?? metricSource?.suggestedAction ?? null; return action ? : null; })()} - {/* v1.18.9 — quiet per-message token footer. The just-finished - streaming turn reads the `done.usage` envelope; a persisted / - reloaded turn reads the message's own `tokensUsed` + `model`. - Skipped on in-flight, errored, and refusal turns. */} - {!inProgress && !errorCode && providerType !== "refusal" && ( - )} - {/* v1.22 — per-message hover action row. Icon-only, muted until the - bubble is hovered / focused on pointer devices; always visible on - touch (no hover to reveal it). Copy + Read-aloud + Good / Bad - feedback + Try-again, with the timestamp trailing. Only settled - persisted assistant turns get the row (skipped for refusals, - errors, in-flight stream bubbles). */} - {!inProgress && !errorCode && providerType !== "refusal" && content && ( -
- - {!streaming && } - {messageId && } - {!streaming && onRegenerate && ( - - )} - {createdAt && } -
- )} + {/* The follow-up chips of the latest answer, in its own column. */} + {followUps && onFollowUp ? ( + + ) : null}
); @@ -1028,107 +776,6 @@ function ChatBubbleImpl({ export const ChatBubble = memo(ChatBubbleImpl, areChatBubblePropsEqual); ChatBubble.displayName = "ChatBubble"; -/** - * v1.16.8 — per-message remember control under the user bubble. - * - * One tap stores the message text into the structured self-context via - * `POST /api/coach/about-me/adopt` (no `question` — the server matches - * the target field from the text itself: an allergy statement lands on - * the allergies field, a stated condition on conditions, everything - * else on the coach-focus slot). The stored text is visible and - * editable under Settings → AI and rides every future Coach system - * prompt. Settled states mirror `SelfContextAdoptOffer`: a short - * confirmation replaces the button after an adoption or a server-side - * dedupe; a failure surfaces a toast and the button stays tappable. - */ -function RememberUserMessage({ content }: { content: string }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); - // On settle the button unmounts while it holds focus, which would - // drop keyboard focus to . The confirmation paragraph takes - // the focus instead (`tabIndex={-1}` + programmatic focus) so the - // reading position survives the swap. - const statusRef = useRef(null); - useEffect(() => { - if (settled) statusRef.current?.focus(); - }, [settled]); - - const remember = useMutation({ - mutationFn: async () => { - return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { - answer: content, - }); - }, - onSuccess: (data) => { - if (data.adopted) { - // The adoption changed the stored self-context — refresh the - // Settings → AI about-me read so it shows the new entry. - void queryClient.invalidateQueries({ - queryKey: queryKeys.coachAboutMe(), - }); - } - setSettled(data.adopted ? "adopted" : "duplicate"); - }, - onError: () => { - toast.error(t("insights.coach.rememberMessage.failed")); - }, - }); - - if (settled) { - return ( -

-

- ); - } - - return ( - - ); -} - /** * v1.16.1 — classic chat typing indicator: three dots pulsing in * sequence inside the assistant bubble, shown only between submit and @@ -1163,90 +810,3 @@ export function TypingDots({ label }: { label: string }) {
); } - -interface CoachMessageFeedbackProps { - messageId: string; -} - -function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) { - const { t } = useTranslations(); - const [submittedRating, setSubmittedRating] = useState< - "helpful" | "unhelpful" | null - >(null); - - const submit = useMutation({ - mutationFn: async (rating: "helpful" | "unhelpful") => { - try { - await apiPost(`/api/insights/chat/messages/${messageId}/feedback`, { - rating, - }); - } catch (err) { - // Treat 409 (already_rated) as a successful no-op so the user - // never sees an error toast for double-clicking the same chip. - if (!(err instanceof ApiError && err.status === 409)) { - throw err; - } - } - return rating; - }, - onSuccess: (rating) => setSubmittedRating(rating), - // v1.16.4 — a failed rating used to fail silently; the chips stayed - // tappable with no signal that nothing was recorded. - onError: () => { - toast.error(t("insights.coach.feedbackError")); - }, - }); - - // v1.22 — icon-only feedback in the per-message action row. After a rating - // lands, the chosen thumb stays visible in its confirming colour (the other - // is dropped) with an `sr-only` thanks so the signal is legible without a - // text caption breaking the icon row. - if (submittedRating) { - return ( - - {t("insights.coach.feedbackThanks")} - {submittedRating === "helpful" ? ( - - ); - } - - const helpfulLabel = t("insights.coach.feedbackHelpful"); - const unhelpfulLabel = t("insights.coach.feedbackUnhelpful"); - return ( -
- - -
- ); -} diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index a0ac9c1dd..ff6265743 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,15 +1,13 @@ "use client"; import { useEffect, useReducer, useRef, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useMutation, useQueries, useQuery, useQueryClient, } from "@tanstack/react-query"; -import { Paperclip, Plus, Settings, Sparkles } from "lucide-react"; +import { Paperclip, Plus, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -56,6 +54,8 @@ import { type InterleavedThreadItem, } from "./message-thread"; import { CoachClarificationCard } from "./clarification-card"; +import { CoachSettingsOverlay } from "./coach-settings-overlay"; +import { ConversationsPanel } from "./conversations-panel"; import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; import { SourcesRail } from "./sources-rail"; @@ -260,6 +260,23 @@ export interface CoachConversationProps { * the surface-toggle preserves scope. The page omits it. */ registerConversationIdGetter?: (getter: () => string | null) => void; + /** + * Page surface only: `/coach?settings=data` (the link from Settings → + * Coach) opens the settings gear on "What I can see". Read once, on mount. + */ + openSettingsOnData?: boolean; +} + +/** + * The page's URL for a conversation: `/coach?c=`, or bare `/coach` for a + * new chat. Written with `history.replaceState` (the App Router keeps + * `useSearchParams` in step with it) so a reload lands on the same thread + * without a server round trip or a new history entry per switch. + */ +function coachPageHref(conversationId: string | null): string { + return conversationId + ? `/coach?c=${encodeURIComponent(conversationId)}` + : "/coach"; } export function CoachConversation({ @@ -280,9 +297,9 @@ export function CoachConversation({ initialDocumentId, initialWorkoutId, registerConversationIdGetter, + openSettingsOnData = false, }: CoachConversationProps) { const { t } = useTranslations(); - const router = useRouter(); const [currentConversationId, setCurrentConversationId] = useState< string | null @@ -292,6 +309,7 @@ export function CoachConversation({ ); const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); + const [drawerSettingsOpen, setDrawerSettingsOpen] = useState(false); const [inputValue, setInputValue] = useResettableValue(prefill ?? ""); // v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet // offer to fold a clarifying-question answer back into the @@ -354,9 +372,19 @@ export function CoachConversation({ const attachDoc = useAttachCoachDocument(currentConversationId ?? ""); const detachDoc = useDetachCoachDocument(currentConversationId ?? ""); + // Page surface: keep `?c=` in step with the open thread, so a reload (or + // a shared tab) lands on it. The drawer has no URL of its own. + function syncPageUrl(conversationId: string | null) { + if (surface !== "page" || typeof window === "undefined") return; + const href = coachPageHref(conversationId); + if (window.location.pathname + window.location.search === href) return; + window.history.replaceState(window.history.state, "", href); + } + const send = useSendCoachMessage({ onDone: (resolvedId) => { setCurrentConversationId(resolvedId); + syncPageUrl(resolvedId); }, }); @@ -791,6 +819,27 @@ export function CoachConversation({ setActiveWorkoutId(null); dispatchGuided({ type: "RESET" }); send.reset(); + syncPageUrl(null); + } + + // Open a stored conversation. Shared by the page's panel and the drawer's + // history tray, so a switch resets the same state on both surfaces. + function selectConversation(id: string) { + setCurrentConversationId(id); + setHistoryTrayOpen(false); + setPendingAdopt(null); + // v1.29.x (S7) — the selected thread's own `fenced` flag + `attachments` + // are authoritative; drop any staged `?doc=` seed so a pending attachment + // can't leak across a thread switch. + setPendingAttachmentIds([]); + dispatchGuided({ type: "RESET" }); + // Drop the finished stream's trailing state, as `handleNewChat` does. + // Without it the previous thread's last assistant or error bubble renders + // appended to the newly selected thread — `streamingActive` stays true + // because that messageId is absent from the new thread's messages — + // until the next send. + send.reset(); + syncPageUrl(id); } const title = conversation?.title ?? t("insights.coach.newChat"); @@ -840,10 +889,6 @@ export function CoachConversation({ // forking any composer logic (dictation, auto-grow, send/stop, the // guided-question placeholder all behave identically in both spots). // - // v1.18.11 (W11) — on the page surface the composer is the control hub: - // it grows a leading `+` actions menu (new chat + open conversations) and - // a settings deep-link. The drawer keeps its own header for those, so the - // hub is page-only. // v1.29.x (S7) — the composer with its attachment chrome: the pills row + the // "still indexing" hint stack directly above the input, so they travel with // the composer into BOTH the new-chat hero and the docked conversation view. @@ -894,9 +939,6 @@ export function CoachConversation({ ? t("insights.coach.guided.answerPlaceholder") : undefined } - showHub={surface === "page"} - onNewChat={handleNewChat} - onOpenHistory={() => router.push("/coach/conversations")} attachEnabled={attachEnabled} onPickFromVault={() => setPickerOpen(true)} onUploadNew={handleUploadNew} @@ -1140,50 +1182,56 @@ export function CoachConversation({
) : null; - // v1.18.11 (W11) — the PAGE surface drops the top header bar and the - // rail-tray strip entirely. The composer is the single control hub; the - // thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer - // (`mx-auto max-w-2xl`) share ONE centred, max-width-capped column so the - // composer never changes width between the new-chat hero and an active - // conversation. The drawer surface keeps its own header + body chrome - // (handled below) untouched. + // The PAGE surface has no header bar of its own. Conversations, New chat + // and the settings gear live in the conversations panel on the right; its + // toggle sits in the app's top bar. The drawer surface keeps its own header + // + body chrome (handled below). if (surface === "page") { return (
- {docScopeBanner} - {!heroActive ?

{title}

: null} - {/* v1.21.4 (A) — the page-toolbar gear was removed; Settings now lives - in the composer's `+` actions menu alongside New chat and - Conversations, keeping the page chrome to the composer alone. */} - {heroActive ? ( - - ) : ( - <> -
- -
- {/* Docked composer — the SAME centred, capped column as the - thread (and the hero composer), so the width is constant - across the new-chat → conversation transition. */} -
-
{composerStack}
-
- - )} + {/* The conversation column. Its thread and composer keep one centred, + capped column (`max-w-2xl`) across the new-chat hero and an active + conversation, and that width does not change when the panel + beside it opens or closes: only the centring moves. */} +
+ {docScopeBanner} + {!heroActive ?

{title}

: null} + {heroActive ? ( + + ) : ( + <> +
+ +
+
+
{composerStack}
+
+ + )} +
+ {attachEnabled ? (
-
- {sendButton}
+
{sendButton}
diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx new file mode 100644 index 000000000..8c73b35a1 --- /dev/null +++ b/src/components/insights/coach-panel/coach-model-picker.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field"; +import { + ANTHROPIC_MODEL_PRESETS, + LOCAL_MODEL_PRESETS, + OPENAI_MODEL_PRESETS, + uiToLegacyProviderEnum, + type ProviderChainData, + type ProviderType, + type ReasoningEffort, + type UserAIProvider, +} from "@/components/settings/ai/shared"; +import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; + +/** + * The quick model choice in the Coach's settings overlay: which provider + * answers, which model it uses, and how hard a reasoning model thinks. + * + * It is a shortcut onto the same settings Settings → AI writes, never a second + * copy of them. Keys, base URLs, the Codex sign-in and the order of the + * fallback chain stay there; this reads the same two queries and writes + * through the same two routes: + * - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved + * to the front and every other entry in its current order. The entries' + * reasoning settings are not sent, so the server keeps what it stored. + * - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person + * owns the setting (their own key's provider, or their gateway). The + * operator's shared provider and Codex show their state read-only. + * Every write invalidates the AI input keys, so the capability on + * `/api/auth/me` comes back from the server rather than being guessed here. + */ + +/** The configured chain with `provider` first; everything else keeps order. */ +export function chainWithFirst( + chain: ProviderChainData["configuredChain"], + provider: ProviderType, +): { providerType: ProviderType; priority: number; enabled: boolean }[] { + const rest = chain.filter((entry) => entry.providerType !== provider); + const ordered = [ + { providerType: provider, enabled: true }, + ...rest.map((entry) => ({ + providerType: entry.providerType, + enabled: entry.enabled, + })), + ]; + return ordered.map((entry, idx) => ({ ...entry, priority: idx + 1 })); +} + +/** The providers a person can switch to: enabled and able to answer. */ +export function selectableProviders( + chain: ProviderChainData["configuredChain"], +): ProviderType[] { + return chain + .filter((entry) => entry.enabled && entry.available) + .map((entry) => entry.providerType); +} + +/** + * True when a saved switch did not take: the chain the server resolves after + * the save is not led by the provider the person chose. A provider whose key + * stops working between the read and the save lands here. + */ +export function switchDidNotTake( + chosen: ProviderType, + fresh: Pick, +): boolean { + return fresh.activeProvider !== chosen; +} + +/** + * The provider the server answers with when the select has no option for it: + * the operator's central Codex (never a chain entry), or an entry that answers + * without being offered. Shown as a disabled option so the select tells the + * truth instead of showing its first option as active. + */ +export function activeOutsideOptions( + active: string | null, + options: readonly string[], +): string | null { + return active !== null && !options.includes(active) ? active : null; +} + +export type ModelControl = + | { kind: "preset"; presets: readonly string[]; value: string | null } + | { kind: "gateway"; value: string | null } + | { kind: "managed" }; + +const PRESETS: Partial> = { + openai: OPENAI_MODEL_PRESETS, + anthropic: ANTHROPIC_MODEL_PRESETS, + local: LOCAL_MODEL_PRESETS, +}; + +/** + * Which model control the active provider gets. The model column is shared by + * the OpenAI, Anthropic and Local providers and belongs to whichever of them + * the person configured, so the control is offered only for that one; a + * model name chosen for another would be sent to the wrong vendor. + */ +export function modelControlFor( + active: ProviderType | null, + userProvider: UserAIProvider | null | undefined, +): ModelControl { + if (!active || !userProvider) return { kind: "managed" }; + if (active === "openai-compatible") { + return { kind: "gateway", value: userProvider.compatModel }; + } + const presets = PRESETS[active]; + if (presets && userProvider.provider === uiToLegacyProviderEnum(active)) { + return { kind: "preset", presets, value: userProvider.model }; + } + return { kind: "managed" }; +} + +/** The reasoning setting the active provider carries, if any. */ +export function reasoningFieldFor( + active: ProviderType | null, + userProvider: UserAIProvider | null | undefined, +): { + field: "localReasoningEffort" | "compatReasoningEffort"; + value: ReasoningEffort | null; +} | null { + if (active === "local") { + return { + field: "localReasoningEffort", + value: userProvider?.localReasoningEffort ?? null, + }; + } + if (active === "openai-compatible") { + return { + field: "compatReasoningEffort", + value: userProvider?.compatReasoningEffort ?? null, + }; + } + return null; +} + +export function CoachModelPicker() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const chainQuery = useQuery({ + queryKey: queryKeys.insightsProviderChain(), + queryFn: () => apiGet("/api/insights/provider-chain"), + }); + const providerQuery = useQuery({ + queryKey: queryKeys.userAiProvider(), + queryFn: () => apiGet("/api/user/ai-provider"), + }); + + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const onSaved = () => { + setError(null); + void invalidateKeys(queryClient, aiInputDependentKeys); + }; + const onFailed = (e: unknown) => + setError( + e instanceof Error && e.message + ? e.message + : t("insights.coach.frame.saveFailed"), + ); + + const switchProvider = useMutation({ + mutationFn: async (provider: ProviderType) => { + setNotice(null); + await apiPut("/api/insights/provider-chain", { + chain: chainWithFirst(chainQuery.data?.configuredChain ?? [], provider), + }); + // Read back what the server now resolves, so a switch that did not + // take is said rather than shown as the select jumping back. + return apiGet("/api/insights/provider-chain"); + }, + onSuccess: (fresh, provider) => { + queryClient.setQueryData(queryKeys.insightsProviderChain(), fresh); + onSaved(); + if (switchDidNotTake(provider, fresh)) { + setNotice( + t("insights.coach.frame.providerNotActive", { + provider: t(`settings.ai.providerChain.types.${provider}`), + }), + ); + } + }, + onError: onFailed, + }); + const patchProvider = useMutation({ + mutationFn: (body: Record) => + apiPatch("/api/user/ai-provider", body), + onSuccess: onSaved, + onError: onFailed, + }); + + const chain = chainQuery.data; + const userProvider = providerQuery.data; + const active = chain?.activeProvider ?? null; + const options = chain ? selectableProviders(chain.configuredChain) : []; + const model = modelControlFor(active, userProvider); + const reasoning = reasoningFieldFor(active, userProvider); + const busy = switchProvider.isPending || patchProvider.isPending; + // The server may answer with `admin-codex`, which the UI's type omits. + const unlisted = activeOutsideOptions(active as string | null, options); + + if (chainQuery.isError || providerQuery.isError) { + return ( +
+

+ {t("common.loadFailed")} +

+ +
+ ); + } + + if (!chain || !userProvider) { + return ( +

+

+ ); + } + + return ( +
+
+ + { + const next = e.target.value as ProviderType; + if (next && next !== active) switchProvider.mutate(next); + }} + > + {active === null ? ( + + ) : null} + {unlisted ? ( + + ) : null} + {options.map((p) => ( + + ))} + + {unlisted === "admin-codex" ? ( +

+ {t("insights.coach.frame.operatorManaged")} +

+ ) : null} +
+ + {model.kind === "preset" ? ( +
+ + + patchProvider.mutate({ model: e.target.value || null }) + } + > + + {model.value && !model.presets.includes(model.value) ? ( + + ) : null} + {model.presets.map((m) => ( + + ))} + +
+ ) : model.kind === "gateway" ? ( + patchProvider.mutate({ compatModel: next })} + /> + ) : ( +

+ {t("insights.coach.frame.modelManaged")} +

+ )} + + {reasoning ? ( + patchProvider.mutate({ [reasoning.field]: next })} + /> + ) : null} + + {busy ? ( +

+ {t("insights.coach.frame.saving")} +

+ ) : null} + {notice ? ( +

+ {notice} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +/** A gateway names its own models, so its model is free text with a save. */ +function GatewayModelField({ + value, + disabled, + onSave, +}: { + value: string | null; + disabled: boolean; + onSave: (next: string | null) => void; +}) { + const { t } = useTranslations(); + const [draft, setDraft] = useState(value ?? ""); + const changed = draft.trim() !== (value ?? ""); + return ( +
{ + e.preventDefault(); + if (changed) onSave(draft.trim() || null); + }} + > + +
+ setDraft(e.target.value)} + disabled={disabled} + className="min-w-0 flex-1" + /> + +
+
+ ); +} diff --git a/src/components/insights/coach-panel/coach-settings-body.tsx b/src/components/insights/coach-panel/coach-settings-body.tsx new file mode 100644 index 000000000..2a844cbb9 --- /dev/null +++ b/src/components/insights/coach-panel/coach-settings-body.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Link from "next/link"; +import { Cpu } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +import { CoachModelPicker } from "./coach-model-picker"; +import { SourcesRail } from "./sources-rail"; + +/** + * The content of the Coach's quick settings (see `coach-settings-overlay`): + * the model choice, "What I can see" (the existing sources rail, wired to the + * saved Coach preferences: data areas and how far back the Coach looks) and + * a way on to the full AI settings. + * + * `focusData` lands focus on the "What I can see" section when the body + * mounts, for the `/coach?settings=data` deep link. It runs here rather than + * in the overlay because the body loads on demand and may arrive after the + * popover or sheet has already placed focus. + */ +export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data"; + +export function CoachSettingsBody({ + focusData = false, +}: { + focusData?: boolean; +}) { + const { t } = useTranslations(); + const dataRef = useRef(null); + useEffect(() => { + if (!focusData) return; + const frame = requestAnimationFrame(() => { + dataRef.current?.focus(); + dataRef.current?.scrollIntoView({ block: "start" }); + }); + return () => cancelAnimationFrame(frame); + }, [focusData]); + return ( +
+
+

+

+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/components/insights/coach-panel/coach-settings-overlay.tsx b/src/components/insights/coach-panel/coach-settings-overlay.tsx new file mode 100644 index 000000000..1a0d0a12a --- /dev/null +++ b/src/components/insights/coach-panel/coach-settings-overlay.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useRef } from "react"; +import dynamic from "next/dynamic"; +import { Loader2, Settings } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +// The body (model picker, the sources rail, their queries) loads when the +// gear is first opened, as one shared chunk, instead of riding along with +// every route that can mount the Coach drawer. +const CoachSettingsBody = dynamic( + () => import("./coach-settings-body").then((m) => m.CoachSettingsBody), + { ssr: false, loading: () => }, +); + +function BodyLoading() { + return ( +
+
+ ); +} + +/** + * The Coach's quick settings, behind the gear: which model answers, and what + * the Coach may read. + * + * A popover anchored on the gear from `md` up, a bottom sheet on a phone. + * Both trap focus, close on Escape and hand focus back to the gear. Nothing + * in here does model work; the provider write invalidates the AI input keys + * and the capability comes back from the server. + * + * `focusData` opens it on the "What I can see" section: Settings → Coach + * links here with `/coach?settings=data`. + */ +export interface CoachSettingsOverlayProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Open on the "What I can see" section instead of the top. */ + focusData?: boolean; + /** Classes for the gear button, so each header sizes it like its row. */ + className?: string; +} + +export function CoachSettingsOverlay({ + open, + onOpenChange, + focusData = false, + className, +}: CoachSettingsOverlayProps) { + const { t } = useTranslations(); + const isPhone = useIsMobile("md"); + const gearRef = useRef(null); + + const gear = ( + + ); + + if (isPhone) { + return ( + <> + {gear} + { + event.preventDefault(); + gearRef.current?.focus(); + }} + > + + + + ); + } + + return ( + + {gear} + { + // The body moves focus to its data section once it has loaded. + if (focusData) event.preventDefault(); + }} + > +

+ {t("insights.coach.settingsAriaLabel")} +

+ +
+
+ ); +} diff --git a/src/components/insights/coach-panel/conversation-rename.tsx b/src/components/insights/coach-panel/conversation-rename.tsx index a45e123c6..a55664613 100644 --- a/src/components/insights/coach-panel/conversation-rename.tsx +++ b/src/components/insights/coach-panel/conversation-rename.tsx @@ -25,18 +25,39 @@ export function getConversationRenameKeyAction( export interface ConversationRenameProps { id: string; title: string; - compact?: boolean; + /** + * Controlled mode, for a surface that opens the editor from somewhere else + * (the conversations panel's row menu). When set, the component renders no + * trigger of its own: it shows the form while `editing` is true and reports + * the end of an edit (saved or cancelled) through `onEditingChange`. + */ + editing?: boolean; + onEditingChange?: (editing: boolean) => void; } export function ConversationRename({ id, title, - compact = false, + editing: controlledEditing, + onEditingChange, }: ConversationRenameProps) { const { t } = useTranslations(); const rename = useRenameCoachConversation(); - const [editing, setEditing] = useState(false); + const controlled = controlledEditing !== undefined; + const [localEditing, setLocalEditing] = useState(false); + const editing = controlled ? controlledEditing : localEditing; const [draft, setDraft] = useState(title); + // A controlled editor opens from outside, so it seeds its draft from the + // current title each time it opens (render-phase, no effect). + const [seededOpen, setSeededOpen] = useState(false); + if (controlled && editing !== seededOpen) { + setSeededOpen(editing); + if (editing) setDraft(title); + } + function setEditing(next: boolean) { + if (controlled) onEditingChange?.(next); + else setLocalEditing(next); + } const submittingRef = useRef(false); function cancel() { @@ -82,23 +103,27 @@ export function ConversationRename({ const action = getConversationRenameKeyAction(event.key); if (!action) return; event.preventDefault(); + // Escape belongs to the editor while it is open: the panel and the sheet + // around it must not close on the same key press. + event.stopPropagation(); if (action === "cancel") cancel(); else void save(); } if (!editing) { + if (controlled) return null; return ( @@ -115,7 +140,7 @@ export function ConversationRename({ data-slot="coach-conversation-rename-form" className={cn( "bg-background absolute inset-y-1 left-1 z-10 flex min-w-0 items-center gap-1", - compact ? "right-14" : "right-16", + controlled ? "right-1" : "right-16", )} > () => {}; + +/** + * Whether the phone sheet is open. A settings deep link opens it only once + * the viewport is known: the server and hydration renders take the narrow + * (sheet) layout, so opening it from the first render flashed the sheet on + * a desktop before the docked panel replaced it. + */ +export function sheetShown(state: { + chosen: boolean; + deepLinkPending: boolean; + hydrated: boolean; + docked: boolean; +}): boolean { + return ( + state.chosen || (state.deepLinkPending && state.hydrated && !state.docked) + ); +} + +export interface ConversationsPanelProps { + activeId: string | null; + onSelect: (id: string) => void; + onNewChat: () => void; + /** + * `/coach?settings=data`: reveal the panel and open the settings gear on + * the "What I can see" section. Read once, on mount. + */ + openSettingsOnData?: boolean; +} + +export function ConversationsPanel({ + activeId, + onSelect, + onNewChat, + openSettingsOnData = false, +}: ConversationsPanelProps) { + const { t } = useTranslations(); + const { docked, open: rememberedOpen, setOpen } = useCoachPanelOpen(); + const toggleRef = useRef(null); + // Owned here, not by the list, so a pending delete keeps its undo window + // when the sheet (and the list inside it) closes. + const deletion = useDeleteCoachConversationWithUndo(); + + // A settings deep-link shows the panel without rewriting the remembered + // choice; the next toggle settles it. + const [revealed, setRevealed] = useState(openSettingsOnData); + const hydrated = useSyncExternalStore( + noSubscription, + () => true, + () => false, + ); + const [deepLinkPending, setDeepLinkPending] = useState(openSettingsOnData); + const [sheetChosen, setSheetChosen] = useState(false); + const sheetOpen = sheetShown({ + chosen: sheetChosen, + deepLinkPending, + hydrated, + docked, + }); + // On a docked viewport the deep link is served by `revealed`; it must not + // open the sheet later if the window narrows. + if (deepLinkPending && hydrated && docked) setDeepLinkPending(false); + const setSheetOpen = (next: boolean) => { + setDeepLinkPending(false); + setSheetChosen(next); + }; + const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData); + const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData); + + const dockedOpen = rememberedOpen || revealed; + const expanded = docked ? dockedOpen : sheetOpen; + + function toggle() { + if (docked) { + setRevealed(false); + setOpen(!dockedOpen); + } else { + setSheetOpen(!sheetOpen); + } + } + + function closeDocked() { + setRevealed(false); + setOpen(false); + toggleRef.current?.focus(); + } + + function afterPick() { + if (!docked) setSheetOpen(false); + } + + const toggleLabel = expanded + ? t("insights.coach.frame.hidePanel") + : t("insights.coach.frame.showPanel"); + + const header = (inSheet: boolean) => ( +
+ {inSheet ? ( + + {t("insights.coach.historyTitle")} + + ) : ( +

+ {t("insights.coach.historyTitle")} +

+ )} + + { + setSettingsOpen(next); + if (!next) setSettingsOnData(false); + }} + focusData={settingsOnData} + /> + {inSheet ? ( + + + + ) : null} +
+ ); + + const body = (inSheet: boolean) => ( + <> + {header(inSheet)} + { + onSelect(id); + afterPick(); + }} + deletion={deletion} + onDeleteActive={() => onNewChat()} + onUndoDeleteActive={(id) => onSelect(id)} + className="min-h-0 flex-1" + /> + + + ); + + return ( + <> + + + + + {docked ? ( + + ) : ( + + { + // Land on the sheet itself, not its first control: on a phone + // focusing the search field would raise the keyboard over the + // list the person opened the sheet to read. + event.preventDefault(); + (event.currentTarget as HTMLElement | null)?.focus(); + }} + onCloseAutoFocus={(event) => { + // The toggle lives in the top bar, outside the sheet; hand + // focus back to it explicitly. + event.preventDefault(); + toggleRef.current?.focus(); + }} + onEscapeKeyDown={(event) => { + // The rename field takes Escape for itself (cancel the edit); + // the sheet stays open. Radix listens in the capture phase, so + // the field's own handler has not run yet when this fires. + const target = event.target as Element | null; + if ( + target?.closest?.( + '[data-slot="coach-conversation-rename-form"]', + ) + ) { + event.preventDefault(); + } + }} + > + + {t("insights.coach.frame.panelDescription")} + + {body(true)} + + + )} + + ); +} diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx index 525319dcc..64e5561c6 100644 --- a/src/components/insights/coach-panel/follow-up-chips.tsx +++ b/src/components/insights/coach-panel/follow-up-chips.tsx @@ -2,7 +2,9 @@ /** * v1.39.4 — up to three follow-up chips under the latest assistant reply. - * A tap sends the chip's label as the message with + * They sit inside the answer's own column, on the bubble's left edge and at + * the answer's text size, so they read as part of the reply rather than as + * thread chrome. A tap sends the chip's label as the message with * `followUp: { messageId, id }`; the server resolves what the chip asks for. * * The label is the server's rendering in the request locale. It is not @@ -46,7 +48,7 @@ export function CoachFollowUpChips({ aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)} data-slot="coach-follow-up-chips" data-message-id={messageId} - className="flex flex-wrap gap-2" + className="flex max-w-full flex-wrap gap-2" > {chips.map((chip) => ( - - {/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0` - reveal: touch surfaces have no hover, so the delete - affordance was invisible on mobile. The button - sits at 44 px always-visible per the WCAG 2.5.5 - floor. v1.30.1 M5 — one tap deletes (with an Undo - toast); the old arm/confirm double-tap is gone. */} - - - ); - }) + groups.map((group) => ( +
+ {/* h3: one step under the panel's h2 title, and a sibling of the + list's own h3 label where that shows (heading order). */} +

+ {t(group.labelKey)} +

+
    + {group.conversations.map((c) => ( + + ))} +
+
+ )) )} - {/* v1.30.2 (QoL H1) — IntersectionObserver sentinel: scrolling this - div into the rail's own scrollport pulls the next cursor page. - Empty + aria-hidden — the loading row right below it is the - a11y-visible signal. */} + {/* IntersectionObserver sentinel: scrolling it into the list's own + scrollport pulls the next cursor page. */} {visible.length > 0 && hasNextPage ? ( ); } + +function HistoryRow({ + conversation: c, + active, + onSelect, + onDelete, +}: { + conversation: CoachConversationDTO; + active: boolean; + onSelect: (id: string) => void; + onDelete: (id: string) => void; +}) { + const { t } = useTranslations(); + const [renaming, setRenaming] = useState(false); + const menuTriggerRef = useRef(null); + // Set when the menu closes INTO the rename editor, so the menu does not + // hand focus back to its trigger and steal it from the title field. + const openingRenameRef = useRef(false); + + function endRename() { + setRenaming(false); + // Back to the row's menu button, where the edit started. + requestAnimationFrame(() => menuTriggerRef.current?.focus()); + } + + return ( +
  • + + + {renaming ? null : ( + + + + + { + if (openingRenameRef.current) { + openingRenameRef.current = false; + event.preventDefault(); + } + }} + > + { + openingRenameRef.current = true; + setRenaming(true); + }} + > + + onDelete(c.id)} + > + + + + )} + + { + if (!next) endRename(); + }} + /> +
  • + ); +} diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx new file mode 100644 index 000000000..f88e4dc73 --- /dev/null +++ b/src/components/insights/coach-panel/message-actions.tsx @@ -0,0 +1,356 @@ +"use client"; + +/** + * The one action row under a Coach message, for both roles. + * + * Assistant: a single line. Left, the icon actions in reading order (copy, + * read aloud, try again) followed by the time the answer was written; right, + * the tokens and model the answer cost, as meta. The right group truncates + * before the row would wrap, so the row is one line at 390 px too. + * + * User: copy, remember, time, flush with the bubble's right edge. + * + * On pointer devices the row stays quiet until its message is hovered or + * holds focus; on touch it is always visible (there is nothing to hover). + * `opacity-0` rather than `invisible` keeps every control focusable, and + * focus reveals the row. + */ +import { useCallback, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + BookmarkCheck, + BookmarkPlus, + Check, + Copy, + Loader2, + RotateCcw, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import type { Formatters } from "@/lib/format-locale"; +import { apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; + +import { + COACH_ICON_BUTTON, + ReadAloudButton, + useClipboardSupported, +} from "./read-aloud"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The quiet-until-hover reveal, scoped to the message's own group. */ +const REVEAL = { + assistant: cn( + "sm:[@media(hover:hover)]:opacity-0", + "sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100", + "sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100", + ), + user: cn( + "sm:[@media(hover:hover)]:opacity-0", + "sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100", + "sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100", + ), +} as const; + +/** + * The visible time of a message: the clock alone today, "yesterday 14:32" + * the day before, and the short date with the clock further back. Days are + * compared in the reader's display zone (the formatters carry it). Pure, so + * the three tiers are pinned by a unit test. + */ +export function messageTimeText( + iso: string, + now: number, + formatters: Pick, + t: Translate, +): string { + const day = formatters.dateShortSmart(iso); + const time = formatters.time(iso); + if (day === formatters.dateShortSmart(new Date(now))) return time; + if (day === formatters.dateShortSmart(new Date(now - DAY_MS))) { + return t("insights.coach.answer.timeYesterday", { time }); + } + return t("insights.coach.answer.timeEarlier", { date: day, time }); +} + +/** + * The answer's cost as one meta string: tokens with the model when both are + * known, tokens alone otherwise, nothing without a count. The count is the + * server's (`done.usage` live, `tokensUsed` on reload), formatted for the + * reader's locale. + */ +export function answerMetaText( + tokens: number | null | undefined, + model: string | null | undefined, + locale: string, + t: Translate, +): string | null { + if (tokens == null) return null; + let count: string; + try { + count = new Intl.NumberFormat(locale).format(tokens); + } catch { + count = String(tokens); + } + return model + ? t("insights.coach.tokensUsedWithModel", { count, model }) + : t("insights.coach.tokensUsed", { count }); +} + +function CopyMessageButton({ + content, + strip, +}: { + content: string; + strip: boolean; +}) { + const { t } = useTranslations(); + const supported = useClipboardSupported(); + const [copied, setCopied] = useState(false); + const handle = useCallback(async () => { + // Assistant prose is copied as the bubble shows it, without chart + // tokens; user text is verbatim. + const text = strip ? stripChartTokens(content) : content; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + toast.error(t("insights.coach.copyMessageError")); + } + }, [content, strip, t]); + // Absent where the Clipboard API is (plain-HTTP self-hosts) rather than + // error-toasting on tap. + if (!supported) return null; + const label = t("insights.coach.copyMessage"); + return ( + + ); +} + +/** Re-runs the question that produced this answer. */ +function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) { + const { t } = useTranslations(); + const label = t("insights.coach.regenerate"); + return ( + + ); +} + +/** + * When the message was written, as text. The visible run is short ("14:32"); + * the full date and time ride the `title` and the screen-reader text. + */ +function MessageTime({ iso }: { iso: string }) { + const { t } = useTranslations(); + const formatters = useFormatters(); + // "Today" is fixed when the row mounts; a thread left open past midnight + // keeps its labels until the next render from fresh data. + const [now] = useState(Date.now); + const full = formatters.dateTime(iso); + return ( + + ); +} + +/** + * Stores the user's message in the self-context (Settings → AI) on one tap, + * so it rides every future system prompt. The server picks the field from + * the text itself. The icon confirms in place and a toast says where it + * went; the button never unmounts, so keyboard focus stays on it. + */ +function RememberMessageButton({ content }: { content: string }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); + + const remember = useMutation({ + mutationFn: async () => + apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { + answer: content, + }), + onSuccess: (data) => { + if (data.adopted) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.coachAboutMe(), + }); + } + setSettled(data.adopted ? "adopted" : "duplicate"); + // A new entry is a write (success); an existing one is an honest + // non-event (neutral), the same mapping `toastWrittenOutcome` uses. + // Called directly: importing the outcome module here regroups the + // shared chunks and costs ~16 KB gz across the client bundle. + (data.adopted ? toast.success : toast.info)( + t( + data.adopted + ? "insights.coach.rememberMessage.done" + : "insights.coach.rememberMessage.duplicate", + ), + ); + }, + onError: () => { + toast.error(t("insights.coach.rememberMessage.failed")); + }, + }); + + const label = settled + ? t("insights.coach.answer.rememberDone") + : t("insights.coach.rememberMessage.action"); + const inert = settled !== null || remember.isPending; + return ( + + ); +} + +export interface AssistantMessageActionsProps { + content: string; + /** Read aloud and try again wait until the answer has settled. */ + streaming: boolean; + createdAt?: string; + onRegenerate?: () => void; + tokens?: number | null; + model?: string | null; +} + +export function AssistantMessageActions({ + content, + streaming, + createdAt, + onRegenerate, + tokens, + model, +}: AssistantMessageActionsProps) { + const { t, locale } = useTranslations(); + const meta = answerMetaText(tokens, model, resolveIntlLocale(locale), t); + return ( +
    +
    + + {!streaming && } + {!streaming && onRegenerate && ( + + )} + {createdAt && } +
    +

    + {meta} +

    +
    + ); +} + +export interface UserMessageActionsProps { + content: string; + /** Present once the message is persisted; an optimistic copy has none. */ + messageId?: string; + createdAt?: string; +} + +export function UserMessageActions({ + content, + messageId, + createdAt, +}: UserMessageActionsProps) { + // Only a persisted message that fits the self-context field can be kept. + const canRemember = !!messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS; + return ( +
    + + {canRemember && } + {createdAt && } +
    + ); +} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index e87246f5e..3d2981f04 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,6 +1,13 @@ "use client"; -import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -8,9 +15,10 @@ import { scrollBehaviorForUser } from "@/lib/motion"; import { useTranslations } from "@/lib/i18n/context"; import { PlanProposalCards } from "./plan-proposal-card"; -import { ChatBubble } from "./chat-bubble"; -import { CoachFollowUpChips } from "./follow-up-chips"; +import { ChatBubble, type FollowUpOffer } from "./chat-bubble"; import { CoachMessageDatesProvider } from "./coach-results"; +import { ScrollToBottomButton } from "./scroll-to-bottom-button"; +import { focusCoachComposer } from "./composer-focus"; import { EMPTY_LIVE_TURN_KEYS, messageRenderKey, @@ -50,6 +58,9 @@ export { * list grows OR the streaming-content length changes. The user can * scroll up to read history; we suppress auto-scroll until the next * "new message" tick if they're not already pinned to the bottom. + * Away from the end, a round button fades in over the thread's foot and + * brings the reader back (`ScrollToBottomButton`); a reply streaming in + * meanwhile never pulls the view down by itself. * * Visual identity: user bubbles right-aligned, Dracula purple accent; * assistant bubbles left-aligned with the gradient sparkle avatar @@ -268,14 +279,10 @@ export function MessageThread({ const { t } = useTranslations(); const scrollerRef = useRef(null); const wasPinnedRef = useRef(true); - - // v1.4.27 F14 — the evidence disclosure used to honour an opt-in - // `coachPrefs.showEvidenceByDefault` flag that surfaced the raw - // measurement values unconditionally. The flag created an UX trap - // (the maintainer 2026-05-15: "literal metric values exposed under every - // bubble") so the disclosure is now collapsed by default for every - // reply. The user expands by click; the pref is retired in the - // settings sheet so nothing flips it back on. + // The same pinned state as a render value, for the jump-to-latest button. + // The ref stays the source for the auto-scroll (it must not wait for a + // render); the state follows it at most once per frame. + const [pinned, setPinned] = useState(true); const messages: CoachMessageDTO[] = useMemo( () => conversation?.messages ?? [], @@ -334,18 +341,65 @@ export function MessageThread({ optimisticActive && optimisticUser ? optimisticUser.content : null, ); - const chips = latestFollowUps(messages, streaming); + // The chips go to the bubble of the message that offered them, so they + // sit in that answer's column. One object per offer, so the memoised + // bubble sees the same value until the offer itself changes. + const latest = latestFollowUps(messages, streaming); + const chipsMessageId = latest?.messageId ?? null; + const chipsArray = latest?.followUps ?? null; + const chips: FollowUpOffer | null = useMemo( + () => + chipsMessageId && chipsArray + ? { messageId: chipsMessageId, followUps: chipsArray } + : null, + [chipsMessageId, chipsArray], + ); + const chipsFor = (messageId: string | null | undefined) => + onFollowUp && chips && messageId === chips.messageId + ? { + followUps: chips, + followUpsDisabled: !!streaming?.inProgress, + onFollowUp, + } + : {}; + + const hasThread = + messages.length > 0 || + streamingActive || + optimisticActive || + interleavedItems.length > 0; // Track scroll position so we don't yank the viewport when the user - // is browsing earlier turns. + // is browsing earlier turns. Re-attached when the thread mounts (the + // empty state renders no scroller). useEffect(() => { const el = scrollerRef.current; if (!el) return; + let frame = 0; const onScroll = () => { wasPinnedRef.current = isPinnedToBottom(el); + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + setPinned(isPinnedToBottom(el)); + }); }; el.addEventListener("scroll", onScroll, { passive: true }); - return () => el.removeEventListener("scroll", onScroll); + return () => { + el.removeEventListener("scroll", onScroll); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [hasThread]); + + const scrollToLatest = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + // Focus first: pinning hides this button (aria-hidden), and focus must + // not sit on a control that has left the accessibility tree. + focusCoachComposer(); + wasPinnedRef.current = true; + setPinned(true); + el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() }); }, []); // Auto-scroll on new messages OR streaming-content growth, but only @@ -389,12 +443,7 @@ export function MessageThread({ return () => vv.removeEventListener("resize", handleResize); }, []); - if ( - messages.length === 0 && - !streamingActive && - !optimisticActive && - interleavedItems.length === 0 - ) { + if (!hasThread) { return (
    + {/* The positioning frame for the jump-to-latest button. It takes the + thread's place in the flex column; the scroller inside stays the + one scroll container. */}
    *]:mx-auto [&>*]:w-full [&>*]:max-w-2xl", - "scroll-smooth", - // v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox), - // component-scoped via Tailwind arbitrary variants so globals.css - // stays untouched. Replaces the default boxy/angular track. - COACH_SCROLLBAR, - )} + data-slot="coach-message-thread-frame" + className="relative flex min-h-0 flex-1 flex-col" > - {[ - ...messages.map((m, idx) => { - // v1.16.5 — a guided question renders directly above the user - // message that answered it. - const guidedBefore = placement.before.get(m.id); - // v1.22 — "Try again": resolve the user message that produced this - // assistant reply (the nearest preceding user turn) so the surface - // can resubmit it. Null when there is none → no regenerate action. - let precedingUserContent: string | null = null; - if (m.role === "assistant" && onRegenerate) { - for (let j = idx - 1; j >= 0; j--) { - if (messages[j].role === "user") { - precedingUserContent = messages[j].content; - break; +
    *]:mx-auto [&>*]:w-full [&>*]:max-w-2xl", + "scroll-smooth", + // v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox), + // component-scoped via Tailwind arbitrary variants so globals.css + // stays untouched. Replaces the default boxy/angular track. + COACH_SCROLLBAR, + )} + > + {[ + ...messages.map((m, idx) => { + // v1.16.5 — a guided question renders directly above the user + // message that answered it. + const guidedBefore = placement.before.get(m.id); + // v1.22 — "Try again": resolve the user message that produced this + // assistant reply (the nearest preceding user turn) so the surface + // can resubmit it. Null when there is none → no regenerate action. + let precedingUserContent: string | null = null; + if (m.role === "assistant" && onRegenerate) { + for (let j = idx - 1; j >= 0; j--) { + if (messages[j].role === "user") { + precedingUserContent = messages[j].content; + break; + } } } - } - const bubble = ( - 0 - ? { results: streaming.results } - : {})} - onRegenerate={ - precedingUserContent !== null && onRegenerate - ? () => onRegenerate(precedingUserContent as string) - : undefined - } - /> - ); - const key = messageRenderKey(liveKeys, m.id); - return ( - - {guidedBefore?.node} - {m.role === "assistant" ? ( - - {bubble} - - ) : ( - bubble - )} - - ); - }), - // v1.16.5 — guided question whose answer is still optimistic-only. - ...placement.beforeOptimistic.map((i) => ( - {i.node} - )), - // v1.4.25 W5 — the optimistic user bubble sits between the - // persisted history and the streaming assistant placeholder, so - // the visible order matches the person's mental model. The send - // hook drops it once the persisted twin lands. - optimisticActive && optimisticUser ? ( - - ) : null, - streamingActive && streaming ? ( - // Same key, fragment and wrapper as its persisted copy above. - - {null} - + const bubble = ( 0 + ? { results: streaming.results } + : {})} + {...(m.role === "assistant" ? chipsFor(m.id) : {})} + onRegenerate={ + precedingUserContent !== null && onRegenerate + ? () => onRegenerate(precedingUserContent as string) + : undefined + } /> - - - ) : null, - ]} - {/* v1.39.4 — follow-up chips under the latest assistant turn only. */} - {onFollowUp && chips ? ( - - ) : null} - {/* v1.16.5 — thread tail: the current guided question and/or the + ); + const key = messageRenderKey(liveKeys, m.id); + return ( + + {guidedBefore?.node} + {m.role === "assistant" ? ( + + {bubble} + + ) : ( + bubble + )} + + ); + }), + // v1.16.5 — guided question whose answer is still optimistic-only. + ...placement.beforeOptimistic.map((i) => ( + {i.node} + )), + // v1.4.25 W5 — the optimistic user bubble sits between the + // persisted history and the streaming assistant placeholder, so + // the visible order matches the person's mental model. The send + // hook drops it once the persisted twin lands. + optimisticActive && optimisticUser ? ( + + ) : null, + streamingActive && streaming ? ( + // Same key, fragment and wrapper as its persisted copy above. + + {null} + + + + + ) : null, + ]} + {/* v1.16.5 — thread tail: the current guided question and/or the closing summary follow the last completed turn. */} - {placement.tail.map((i) => ( - {i.node} - ))} - {/* Plan-proposal confirm cards for THIS conversation. The extractor + {placement.tail.map((i) => ( + {i.node} + ))} + {/* Plan-proposal confirm cards for THIS conversation. The extractor runs after the turn (memory-refresh worker), so the block appears at the thread tail once the proposal lands rather than under a specific bubble; the component itself slow-polls and renders nothing while the conversation has no open proposal. */} - {conversation ? ( - - ) : null} + {conversation ? ( + + ) : null} +
    +
    ); diff --git a/src/components/insights/coach-panel/message-token-footer.tsx b/src/components/insights/coach-panel/message-token-footer.tsx deleted file mode 100644 index 0efc03521..000000000 --- a/src/components/insights/coach-panel/message-token-footer.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { useTranslations } from "@/lib/i18n/context"; - -/** - * v1.18.9 — quiet per-message token-usage caption under an assistant - * bubble. Server-authoritative: it renders the count the server reported - * (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed` - * on reload), never a client-side recomputation. No cost — BYOK users pay - * the provider directly and a price table would only drift. - * - * Renders nothing when there is no count (user turns, refusals, in-flight - * bubbles, and pre-feature persisted rows all carry null). - */ -export interface MessageTokenFooterProps { - tokens: number | null | undefined; - /** Provider model name (e.g. `gpt-4o`); appended when present. */ - model?: string | null; -} - -export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) { - const { t } = useTranslations(); - if (tokens == null) return null; - - const label = model - ? t("insights.coach.tokensUsedWithModel", { count: tokens, model }) - : t("insights.coach.tokensUsed", { count: tokens }); - - return ( -

    - {label} -

    - ); -} diff --git a/src/components/insights/coach-panel/method-line.tsx b/src/components/insights/coach-panel/method-line.tsx index 60a2227b3..3b6246b8e 100644 --- a/src/components/insights/coach-panel/method-line.tsx +++ b/src/components/insights/coach-panel/method-line.tsx @@ -1,11 +1,10 @@ "use client"; /** - * v1.39.4 — how an answer was worked out, as one muted line inside the - * evidence disclosure: sources, windows, counts and aggregation, rendered + * v1.39.4 — how an answer was worked out, as one muted line at the end of + * the open steps list: sources, windows, counts and aggregation, rendered * on the server in the request locale. Never a health value, so it sits in - * the meta tier beside the key values it explains rather than repeating - * them. + * the meta tier. */ import { useTranslations } from "@/lib/i18n/context"; import { COACH_METHOD_KEYS } from "@/lib/ai/coach/dialog-keys"; diff --git a/src/components/insights/coach-panel/read-aloud.tsx b/src/components/insights/coach-panel/read-aloud.tsx index ac94adba6..2fe931eae 100644 --- a/src/components/insights/coach-panel/read-aloud.tsx +++ b/src/components/insights/coach-panel/read-aloud.tsx @@ -260,9 +260,9 @@ export function ReadAloudButton({ content }: { content: string }) { className={COACH_ICON_BUTTON} > {speaking ? ( -
    ) : null} - {/* Window selector — persists to `coachPrefs.defaultWindow` so the - chosen timeframe sticks across drawer opens and matches what - the cog sheet shows. */} + {/* Lookback limit — persists to `coachPrefs.defaultWindow`. The line + under the picker states what the Coach reads at this setting, with + the numbers the server readers use. */}
    - + +

    + {t( + `insights.coach.lookback.${activeText.detailKey}`, + activeText.detailParams, + )} + {bounded ? ` ${t("insights.coach.lookback.note")}` : null} +

    {/* Persisted cluster toggles — the same set the settings cog @@ -201,11 +221,15 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { data-slot="coach-sources-row" data-source={cluster} data-active={checked ? "true" : "false"} + // A switched-off row is told apart by its frame (dashed, no + // fill) and its switch, never by fading the text: opacity over + // muted text fell below WCAG AA in both themes. className={cn( - "border-border/60 bg-muted/30 flex items-center gap-2.5", - "rounded-lg border px-3", + "flex items-center gap-2.5 rounded-lg border px-3", "min-h-11 py-2", - !checked && "opacity-60", + checked + ? "border-border/60 bg-muted/30" + : "border-border border-dashed bg-transparent", )} >
    @@ -216,9 +240,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { {t(`insights.coach.cluster.${cluster}.label`)} - {t("insights.coach.sourcesMemberCount", { - count: memberCount, - })} + {tCount("insights.coach.sourcesMemberCount", memberCount)}
    {pending === cluster ? ( diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx index 4c05a067c..16f354640 100644 --- a/src/components/insights/coach-panel/turn-steps.tsx +++ b/src/components/insights/coach-panel/turn-steps.tsx @@ -13,11 +13,17 @@ * server's enums and counts; a row never shows a value, a tool argument or * model text. * + * The open list also says how the answer was worked out (the method line) + * and holds the tables the answer read without pointing at them. An older + * message saved before steps existed folds into "Looked at 3 areas" and + * lists the areas from its provenance, without counts; a metric token the + * bundle has no name for is left out rather than shown as a raw key. + * * Screen readers hear each completed step once, through a polite status * region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest * wins). A reloaded conversation announces nothing. */ -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { Check, ChevronRight, @@ -36,7 +42,13 @@ import { coachPeriodLabelKey, coachWindowLabelKey, } from "@/lib/ai/coach/dialog-keys"; -import type { CoachStep } from "@/lib/ai/coach/types"; +import type { + CoachMethod, + CoachProvenanceMetric, + CoachStep, +} from "@/lib/ai/coach/types"; + +import { CoachMethodLine } from "./method-line"; export interface CoachTurnStepsProps { steps: CoachStep[]; @@ -44,6 +56,21 @@ export interface CoachTurnStepsProps { active: boolean; } +export interface CoachTurnStepsDetailProps extends CoachTurnStepsProps { + /** + * The provenance metrics, for a message saved before steps existed. Read + * only when `steps` is empty. + */ + areas?: readonly CoachProvenanceMetric[]; + /** How the answer was worked out; shown at the end of the open list. */ + method?: CoachMethod | null; + /** + * The tables the answer read without pointing at them, shown last in the + * open list. Pass it only when there is something to show. + */ + dataUsed?: ReactNode; +} + /** The shortest gap between two screen-reader announcements. */ export const ANNOUNCE_THROTTLE_MS = 1500; @@ -238,6 +265,26 @@ function StepIcon({ step, active }: { step: CoachStep; active: boolean }) { ); } +/** + * The areas an older message drew on, in the order its provenance lists + * them, by their display names. A token without a name in the bundle is + * dropped, and so is `general`, which names no area of the record. + */ +export function legacyAreaLabels( + areas: readonly CoachProvenanceMetric[] | undefined, + t: Translate, +): string[] { + const out: string[] = []; + for (const area of areas ?? []) { + if (area === "general") continue; + const key = `insights.coach.metric.${area}`; + const label = t(key); + if (label === key || out.includes(label)) continue; + out.push(label); + } + return out; +} + /** The rows under the header: one per step, in the order they started. */ export function CoachTurnStepList({ id, @@ -250,7 +297,7 @@ export function CoachTurnStepList({ id={id} data-slot="coach-turn-steps-list" aria-label={t(COACH_STEP_UI_KEYS.listLabel)} - className="border-border/60 mt-1.5 ml-1.5 flex flex-col gap-1 border-l pl-2.5" + className="flex flex-col gap-1" > {steps.map((step) => { const { title, meta } = describeStep(step, t, tCount); @@ -279,7 +326,66 @@ export function CoachTurnStepList({ ); } -export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) { +/** + * The open list: the steps (or an older message's areas), then the method + * line, then the tables the answer read. One left rule holds them together. + */ +export function CoachTurnStepsPanel({ + id, + steps, + active, + areaLabels, + method, + dataUsed, +}: CoachTurnStepsProps & { + id?: string; + areaLabels: string[]; + method?: CoachMethod | null; + dataUsed?: ReactNode; +}) { + const { t } = useTranslations(); + return ( +
    + {steps.length > 0 ? ( + + ) : areaLabels.length > 0 ? ( +
      + {areaLabels.map((label) => ( +
    • +
    • + ))} +
    + ) : null} + {!active && } + {!active && dataUsed} +
    + ); +} + +export function CoachTurnSteps({ + steps, + active, + areas, + method, + dataUsed, +}: CoachTurnStepsDetailProps) { const { t, tCount } = useTranslations(); const listId = useId(); // Closed by default; the reader's toggle holds until the turn ends, and @@ -294,14 +400,31 @@ export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) { t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }), ); - if (steps.length === 0) return null; + const areaLabels = steps.length === 0 ? legacyAreaLabels(areas, t) : []; + const hasMethod = !active && !!method?.text; + const hasDataUsed = !active && dataUsed != null && dataUsed !== false; + if ( + steps.length === 0 && + areaLabels.length === 0 && + !hasMethod && + !hasDataUsed + ) { + return null; + } const open = openOverride ?? false; const current = active ? currentStep(steps) : null; + const header = current + ? null + : steps.length > 0 + ? tCount("coach.step.headerDone", countSources(steps)) + : areaLabels.length > 0 + ? tCount("insights.coach.answer.areasDone", areaLabels.length) + : t(COACH_STEP_UI_KEYS.listLabel); return (
    - {open && } + {open && ( + + )} { + it("renders an empty outlet that takes itself out of the row", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe( + '
    ', + ); + }); + + it("renders nothing in place: page actions only ever appear in the outlet", () => { + // On the server (and before the top bar has mounted) there is no outlet, + // so the page's actions render nowhere rather than inline in the page. + const html = renderToStaticMarkup( +
    + + + +
    , + ); + expect(html).toBe("
    "); + }); +}); diff --git a/src/components/layout/top-bar-actions.tsx b/src/components/layout/top-bar-actions.tsx new file mode 100644 index 000000000..c053846e3 --- /dev/null +++ b/src/components/layout/top-bar-actions.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; + +/** + * A page-owned slot at the trailing edge of the top bar. + * + * The top bar is shell chrome and knows nothing about pages; a page that + * needs a control up there (the Coach's conversations-panel toggle) renders + * `` anywhere in its own tree and the content is portalled + * into ``. Portalling keeps the control inside the + * page's React tree, so it reads the page's state and context directly and + * no node has to be handed across a context on every render. + * + * The slot empties itself when the page unmounts: the portal goes with the + * component that rendered it, so no other route ever shows a stale control. + * + * The outlet's element is held in a module-level store rather than a context + * provider. The shell mounts exactly one top bar, so there is one outlet, and + * a store keeps the shell's tree untouched. + */ +let outlet: HTMLElement | null = null; +const listeners = new Set<() => void>(); + +function setOutlet(node: HTMLElement | null) { + if (outlet === node) return; + outlet = node; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Where the top bar paints page actions. Empty unless a page fills it. */ +export function TopBarActionsOutlet({ className }: { className?: string }) { + return ( +
    + ); +} + +/** Render `children` into the top bar's action slot. */ +export function TopBarActions({ children }: { children: React.ReactNode }) { + const target = useSyncExternalStore( + subscribe, + () => outlet, + () => null, + ); + if (!target) return null; + return createPortal(children, target); +} diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index f2aa2aaf8..71436d8b5 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -15,6 +15,7 @@ import { visibleUtilityDestinations, } from "@/components/layout/nav-model"; import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics"; +import { TopBarActionsOutlet } from "@/components/layout/top-bar-actions"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -106,6 +107,12 @@ export function TopBar() { {/* Desktop: empty spacer (user controls are in sidebar) */}
    + {/* Page-owned actions (the Coach's conversations toggle). `ml-auto` + keeps them at the trailing edge, right before the mobile avatar + menu; `empty:hidden` takes the slot out of the row on every page + that leaves it empty, so the header lays out exactly as before. */} + + {/* Mobile-only auth section (desktop uses sidebar user section) */}
    {isLoading ? ( diff --git a/src/components/settings/__tests__/coach-links-without-coach.test.tsx b/src/components/settings/__tests__/coach-links-without-coach.test.tsx index 8eddb9860..4a2ecb8da 100644 --- a/src/components/settings/__tests__/coach-links-without-coach.test.tsx +++ b/src/components/settings/__tests__/coach-links-without-coach.test.tsx @@ -54,16 +54,16 @@ beforeEach(() => { }); describe("Coach links follow the Coach's capability", () => { - it("the sources pointer links to the Coach while it is available", () => { + it("the sources pointer opens the Coach's settings on what it can see", () => { expect(render()).toContain( - 'href="/coach"', + 'href="/coach?settings=data"', ); }); it("the sources pointer offers no dead link without the Coach", () => { coachState.available = false; const html = render(); - expect(html).not.toContain('href="/coach"'); + expect(html).not.toContain('href="/coach?settings=data"'); expect(html).not.toContain("coach-prefs-sources-pointer"); }); diff --git a/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx b/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx new file mode 100644 index 000000000..a1d4158a6 --- /dev/null +++ b/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx @@ -0,0 +1,76 @@ +/** + * Settings → Coach says what the Coach reads at the saved setting, in one + * sentence, beside the link into the Coach's own settings: how many data + * areas are on, out of how many, and how far back it looks. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: true, + reason: null, + onDeviceAllowed: false, + }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/coach", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + DEFAULT_COACH_CLUSTERS, + DEFAULT_COACH_PREFS, + coachDataClusterEnum, + type CoachPrefs, +} from "@/lib/validations/coach-prefs"; +import { CoachPrefsSection } from "../coach-prefs-section"; + +function summary(prefs: CoachPrefs): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0, staleTime: Infinity } }, + }); + client.setQueryData(queryKeys.coachPrefs(), prefs); + const html = renderToStaticMarkup( + + + + + , + ); + const match = html.match( + /data-slot="coach-prefs-sources-summary"[^>]*>([^<]*) { + it("counts the default areas and says the whole history is read", () => { + expect(summary(DEFAULT_COACH_PREFS)).toBe( + `The Coach sees ${DEFAULT_COACH_CLUSTERS.length} of ${TOTAL} data areas and your whole history.`, + ); + }); + + it("names a bounded lookback in the picker's words", () => { + expect( + summary({ + ...DEFAULT_COACH_PREFS, + dataClusters: ["cardio", "sleep"], + defaultWindow: "last90days", + }), + ).toBe(`The Coach sees 2 of ${TOTAL} data areas and looks back 90 days.`); + expect( + summary({ + ...DEFAULT_COACH_PREFS, + dataClusters: [], + defaultWindow: "lastYear", + }), + ).toBe(`The Coach sees 0 of ${TOTAL} data areas and looks back 12 months.`); + }); +}); diff --git a/src/components/settings/ai/reasoning-effort-field.tsx b/src/components/settings/ai/reasoning-effort-field.tsx index b9bae533f..fee0f4e3f 100644 --- a/src/components/settings/ai/reasoning-effort-field.tsx +++ b/src/components/settings/ai/reasoning-effort-field.tsx @@ -14,20 +14,29 @@ import type { ReasoningEffort } from "./shared"; export function ReasoningEffortField({ id, + disabled = false, value, onChange, + noColon = false, }: { id: string; + /** Locks the select while a write is in flight. */ + disabled?: boolean; value: ReasoningEffort | null; onChange: (next: ReasoningEffort | null) => void; + /** The Coach's quick settings label their fields without a colon. */ + noColon?: boolean; }) { const { t } = useTranslations(); return (
    - + onChange( e.target.value === "" ? null : (e.target.value as ReasoningEffort), diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index 7e12ed1e5..1e1756575 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -41,8 +41,11 @@ import { useTranslations } from "@/lib/i18n/context"; import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs"; import { useAiCapability } from "@/hooks/use-ai-capability"; import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys"; +import { HISTORY_REACH_DAYS, lookbackText } from "@/lib/ai/coach/history-reach"; import { + DEFAULT_COACH_CLUSTERS, DEFAULT_COACH_PREFS, + coachDataClusterEnum, type CoachExcludeMetric, type CoachPrefs, type CoachTone, @@ -70,6 +73,32 @@ const CONTEXT_OPTIONS: CoachExcludeMetric[] = [ "anthropometrics", ]; +/** + * One sentence on what the Coach reads at the saved setting: how many of the + * data areas are on and how far back it looks, in the words the lookback + * picker uses. + */ +function sourcesSummary( + prefs: CoachPrefs, + t: ReturnType["t"], +): string { + const params = { + count: (prefs.dataClusters ?? DEFAULT_COACH_CLUSTERS).length, + total: coachDataClusterEnum.options.length, + }; + const window = prefs.defaultWindow; + if (HISTORY_REACH_DAYS[window] === null) { + return t("insights.coach.settingsSourcesSummaryAll", params); + } + return t("insights.coach.settingsSourcesSummary", { + ...params, + window: t( + `insights.coach.lookback.option.${window}`, + lookbackText(window).optionParams, + ), + }); +} + export interface CoachPrefsSectionProps { isAuthenticated: boolean; } @@ -223,26 +252,37 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
    - {/* Data clusters + analysis window live on ONE owner: the - sources rail inside the chat ("What I draw on"). This card - used to render its own copies of both controls; two writable - surfaces for the same persisted fields is exactly the - redundancy the rail was built to end. A pointer replaces - them. Only while the Coach can open: without it the link - would bounce back to Insights and the rail is out of reach. */} + {/* Data clusters + analysis window live on ONE owner: "What I + can see" behind the Coach's settings gear, which + `/coach?settings=data` opens directly. This card used to render + its own copies of both controls; two writable surfaces for the + same persisted fields is exactly the redundancy the rail was + built to end. It says what the Coach sees at the saved setting + and links there. Only while the Coach can open: without it the + link would bounce back to Insights and the rail is out of + reach. */} {coach.available ? ( -

    - {t("insights.coach.settingsSourcesPointer")}{" "} - - {t("insights.coach.settingsSourcesPointerLink")} - -

    + {sourcesSummary(draft, t)} +

    + +
    ) : null} {/* Exclude metrics — checked = excluded. */} diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index fe98c4f5a..0b9fbeb5e 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -34,6 +34,12 @@ import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; +import { + COACH_HISTORY_WINDOWS, + HISTORY_REACH_DAYS, + lookbackText, +} from "@/lib/ai/coach/history-reach"; +import type { CoachDefaultWindow } from "@/lib/validations/coach-prefs"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Switch } from "@/components/ui/switch"; import { SettingsCardActions } from "./_card-actions"; @@ -776,6 +782,12 @@ function CoachSettingsForm({ const preferences = asRecord(settings.preferences); const excluded = new Set(asStringArray(preferences.excludeMetrics)); const dataClusters = new Set(asStringArray(preferences.dataClusters)); + const savedWindow = asString(preferences.defaultWindow, "allTime"); + const [lookback, setLookback] = useState( + (COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow) + ? (savedWindow as CoachDefaultWindow) + : "allTime", + ); function submit(event: FormEvent) { event.preventDefault(); @@ -847,28 +859,44 @@ function CoachSettingsForm({ -
    diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index c25175db8..b1a7f2a00 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -88,6 +88,13 @@ export interface ResponsiveSheetProps { * default, so the width now resolves from this single source. */ contentWidth?: "md" | "lg" | "2xl" | "3xl" | "4xl" | "6xl"; + /** + * Where focus goes when the surface closes. Radix returns it to a + * `Dialog.Trigger`, and this primitive has none, so a caller whose opener + * should get focus back (a gear, a menu button) hands it over here: call + * `event.preventDefault()` and focus the opener. + */ + onCloseAutoFocus?: (event: Event) => void; children: React.ReactNode; } @@ -136,6 +143,7 @@ export function ResponsiveSheet({ bodyClassName, showCloseButton = true, contentWidth = "md", + onCloseAutoFocus, children, }: ResponsiveSheetProps) { const isMobile = useIsMobile(); @@ -144,6 +152,7 @@ export function ResponsiveSheet({ return ( = {}) { + const map = new Map(Object.entries(initial)); + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + map, + }; +} + +const throwing = { + getItem: () => { + throw new Error("SecurityError"); + }, + setItem: () => { + throw new Error("QuotaExceededError"); + }, +}; + +describe("coach panel open preference", () => { + it("opens the docked panel on a first visit", () => { + const storage = memoryStorage(); + expect(readCoachPanelPreference(storage)).toBeNull(); + expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true); + }); + + it("remembers a closed panel, and an opened one, across reads", () => { + const storage = memoryStorage(); + expect(writeCoachPanelPreference(storage, false)).toBe(true); + expect(storage.map.get(COACH_PANEL_STORAGE_KEY)).toBe("false"); + expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe( + false, + ); + writeCoachPanelPreference(storage, true); + expect(resolveCoachPanelOpen(readCoachPanelPreference(storage))).toBe(true); + }); + + it("treats an unknown stored value as no choice", () => { + const storage = memoryStorage({ [COACH_PANEL_STORAGE_KEY]: "maybe" }); + expect(readCoachPanelPreference(storage)).toBeNull(); + }); + + it("falls back to the default when storage throws or is absent", () => { + expect(readCoachPanelPreference(throwing)).toBeNull(); + expect(writeCoachPanelPreference(throwing, false)).toBe(false); + expect(readCoachPanelPreference(null)).toBeNull(); + expect(writeCoachPanelPreference(undefined, true)).toBe(false); + }); +}); diff --git a/src/hooks/use-coach-panel-open.ts b/src/hooks/use-coach-panel-open.ts new file mode 100644 index 000000000..280d0d34f --- /dev/null +++ b/src/hooks/use-coach-panel-open.ts @@ -0,0 +1,135 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; + +/** + * The Coach page's conversations panel: whether it sits docked beside the + * thread, and whether the person last left it open there. + * + * Docked means a viewport of at least 1280 px (`xl`). Below that the panel is + * a sheet over the page and opens only when asked, so nothing here applies. + * At `xl` it is open on a first visit and remembers the last choice per + * device after that. + * + * The choice is a convenience, not a record: it lives in `localStorage`, every + * read and write is wrapped because storage can throw (private windows, + * blocked site data), and an unreadable store falls back to the first-visit + * default. Both values come through `useSyncExternalStore`, so the server and + * the hydrating client agree (docked = false, open = true) and the live value + * lands on the first client render after hydration. + */ +export const COACH_PANEL_STORAGE_KEY = "healthlog.coach.panelOpen"; + +/** The breakpoint at which the panel docks beside the thread. */ +export const COACH_PANEL_DOCKED_QUERY = "(min-width: 1280px)"; + +/** Same-tab change signal; the `storage` event only fires in other tabs. */ +const CHANGE_EVENT = "healthlog:coach-panel-open"; + +/** + * The last choice made in this tab. Read when the store has nothing, so a + * click still sticks for the session where storage refuses every write. + */ +let sessionChoice: boolean | null = null; + +/** + * The remembered choice. `null` = nothing stored yet (a first visit), or a + * store that cannot be read; the caller treats both as "open". + */ +export function readCoachPanelPreference( + storage: Pick | null | undefined, +): boolean | null { + if (!storage) return null; + try { + const raw = storage.getItem(COACH_PANEL_STORAGE_KEY); + if (raw === "true") return true; + if (raw === "false") return false; + return null; + } catch { + return null; + } +} + +/** Persist the choice. Returns false when the store refused the write. */ +export function writeCoachPanelPreference( + storage: Pick | null | undefined, + open: boolean, +): boolean { + if (!storage) return false; + try { + storage.setItem(COACH_PANEL_STORAGE_KEY, String(open)); + return true; + } catch { + return false; + } +} + +/** The open state the docked panel paints: a first visit opens it. */ +export function resolveCoachPanelOpen(stored: boolean | null): boolean { + return stored ?? true; +} + +function safeLocalStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function subscribePreference(callback: () => void): () => void { + if (typeof window === "undefined") return () => {}; + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === COACH_PANEL_STORAGE_KEY) { + callback(); + } + }; + window.addEventListener("storage", onStorage); + window.addEventListener(CHANGE_EVENT, callback); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(CHANGE_EVENT, callback); + }; +} + +function subscribeDocked(callback: () => void): () => void { + if (typeof window === "undefined" || !window.matchMedia) return () => {}; + const mql = window.matchMedia(COACH_PANEL_DOCKED_QUERY); + mql.addEventListener("change", callback); + return () => mql.removeEventListener("change", callback); +} + +function readDocked(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia(COACH_PANEL_DOCKED_QUERY).matches; +} + +/** + * `docked`: the viewport is wide enough for the panel to sit beside the + * thread. `open`: the remembered docked state (meaningful only when docked). + * `setOpen` persists and notifies every subscriber in this tab. + */ +export function useCoachPanelOpen(): { + docked: boolean; + open: boolean; + setOpen: (open: boolean) => void; +} { + const docked = useSyncExternalStore(subscribeDocked, readDocked, () => false); + const open = useSyncExternalStore( + subscribePreference, + () => + resolveCoachPanelOpen( + readCoachPanelPreference(safeLocalStorage()) ?? sessionChoice, + ), + () => true, + ); + const setOpen = useCallback((next: boolean) => { + sessionChoice = next; + writeCoachPanelPreference(safeLocalStorage(), next); + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(CHANGE_EVENT)); + } + }, []); + return { docked, open, setOpen }; +} diff --git a/src/lib/ai/__tests__/provider-presence.test.ts b/src/lib/ai/__tests__/provider-presence.test.ts index 502b4f1e6..5662de426 100644 --- a/src/lib/ai/__tests__/provider-presence.test.ts +++ b/src/lib/ai/__tests__/provider-presence.test.ts @@ -90,6 +90,7 @@ describe("probeProviderChain", () => { entries: [], localOcrEnabled: false, managedBy: null, + availableTypes: [], }); expect(prisma.user.findUnique).not.toHaveBeenCalled(); }); @@ -123,6 +124,22 @@ describe("probeProviderChain", () => { expect(presence.managedBy).toBe("user"); }); + it("names every type whose credential is present, enabled in the chain or not", async () => { + load( + row({ + aiProviderChain: [ + { providerType: "codex", enabled: true, priority: 0 }, + { providerType: "openai", enabled: false, priority: 1 }, + { providerType: "anthropic", enabled: true, priority: 2 }, + ], + aiOpenaiKeyEncrypted: "enc", + }), + ); + const presence = await probeProviderChain("u1", OWNER); + // Codex is not signed in and no Anthropic key is saved: neither can answer. + expect(presence.availableTypes).toEqual(["openai"]); + }); + it("marks which entries can read an image", async () => { load( row({ @@ -162,6 +179,7 @@ describe("probeProviderChain", () => { entries: [{ providerType: "admin-openai", vision: true }], localOcrEnabled: false, managedBy: "server", + availableTypes: ["admin-openai"], }); }); diff --git a/src/lib/ai/capabilities/resolve.ts b/src/lib/ai/capabilities/resolve.ts index 5b97aee38..0152a7ac7 100644 --- a/src/lib/ai/capabilities/resolve.ts +++ b/src/lib/ai/capabilities/resolve.ts @@ -60,6 +60,12 @@ export interface ProviderPresence { /** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */ localOcrEnabled: boolean; managedBy: AiProviderManagedBy | null; + /** + * Every chain tag whose credential is present, whether or not the person + * enabled it in their chain: what a chain editor may offer as able to + * answer. Absent reads as none, so a hand-built presence offers nothing. + */ + availableTypes?: readonly string[]; /** * The record's `aiResponseTimeoutSeconds`, read off the same row. Not a * presence fact, but the account payload publishes the effective timeout diff --git a/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts new file mode 100644 index 000000000..7b0a50e26 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-history-reach-guard.test.ts @@ -0,0 +1,489 @@ +/** + * The Coach lookback limit holds for every tool. + * + * `coachPrefs.defaultWindow` is a limit: no Coach read may reach + * further back than it, whatever window or period the model asks for. This + * guard runs EVERY tool the model is offered (`COACH_TOOL_DEFS`, `show_result` + * included) through the real executor with a 30-day limit and the widest + * arguments the schema allows (`allTime`, `yearAgo`), once with every snapshot + * section present and once with none (the empty-read path), and records each + * call the executor makes into a reader. A call passes when it carries the + * limit and asks for nothing older than 30 days. A tool passes when it made at + * least one read and every read passed, or when it refused with + * `outside_reach` before reading anything. + * + * A tool added to `COACH_TOOL_DEFS` without a case here fails the first test; + * a case whose tool made no read and did not refuse fails too, so the guard + * cannot go green by having nothing to check. + * + * The second block reads the source: every dispatch branch hands the limit + * on, every Coach `buildCoachSnapshot(` call passes one, and the MCP call + * sites pass none (MCP keeps its own windows). The matchers tolerate any + * whitespace and line breaks, and each asserts a non-zero match count. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; +import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; +import type { + CoachResultPeriod, + CoachScope, + CoachScopeWindow, +} from "@/lib/ai/coach/types"; +import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach"; + +const NOW = new Date("2026-10-04T12:00:00Z"); +const LIMIT_DAYS = 30; +const FLOOR = NOW.getTime() - LIMIT_DAYS * 86_400_000; + +const RANK: Record = { + last7days: 0, + last30days: 1, + last90days: 2, + lastYear: 3, + allTime: 4, +}; + +interface Read { + reader: string; + ok: boolean; + detail: string; +} +const reads: Read[] = []; + +function windowOk(window: CoachScopeWindow | undefined): boolean { + return window === undefined || RANK[window] <= RANK.last30days; +} +function reachOk(reach: CoachHistoryReach | undefined): boolean { + return reach?.days === LIMIT_DAYS; +} +function record(reader: string, ok: boolean, detail: unknown): void { + reads.push({ reader, ok, detail: JSON.stringify(detail) }); +} + +let sections: Record = {}; + +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: async ( + _userId: string, + scope?: CoachScope, + options?: { reach?: CoachHistoryReach }, + ): Promise => { + record( + "buildCoachSnapshot", + reachOk(options?.reach) && windowOk(scope?.window), + { window: scope?.window, reach: options?.reach }, + ); + return { + snapshotJson: JSON.stringify(sections), + sections, + provenance: { windows: [], metrics: [] }, + referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, + }; + }, +})); + +vi.mock("@/lib/ai/coach/results/metric-table-tool", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/ai/coach/results/metric-table-tool") + >(); + return { + ...actual, + readMetricTable: async (args: { + window: CoachScopeWindow; + period: CoachResultPeriod; + now?: Date; + }) => { + const range = actual.resolveTableRange({ + window: args.window, + period: args.period, + timeZone: "UTC", + now: args.now ?? NOW, + }); + record("readMetricTable", range.from.getTime() >= FLOOR - 86_400_000, { + window: args.window, + period: args.period, + from: range.from.toISOString(), + }); + return null; + }, + }; +}); + +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: async ( + _userId: string, + _locale: string, + options?: { reach?: CoachHistoryReach }, + ) => { + record("readCoachCorrelations", reachOk(options?.reach), options); + return { present: false, reason: "no_significant_pattern" }; + }, +})); + +vi.mock("@/lib/ai/coach/illness-snapshot", () => ({ + buildIllnessScores: async ( + _userId: string, + _now?: Date, + reach?: CoachHistoryReach, + ) => { + record("buildIllnessScores", reachOk(reach), reach); + return null; + }, +})); + +vi.mock("@/lib/ai/coach/tools/availability", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveEmptyRead: async (args: { + searchedWindow?: CoachScopeWindow; + reach?: CoachHistoryReach; + }) => { + record( + "resolveEmptyRead", + reachOk(args.reach) && windowOk(args.searchedWindow), + { window: args.searchedWindow, reach: args.reach }, + ); + return { present: false, reason: "no_data" }; + }, + illnessBeyondReach: async (_userId: string, reach: CoachHistoryReach) => { + record("illnessBeyondReach", reachOk(reach), reach); + return false; + }, + cycleRecorded: async () => true, + }; +}); + +vi.mock("@/lib/cycle/gate", () => ({ + isCycleAvailableForUser: async () => true, +})); +vi.mock("@/lib/i18n/user-locale", () => ({ + resolveLocaleForUser: async () => "en", +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: async () => "UTC", +})); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: async () => ({}), +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + readMessageResults: async () => null, +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { + admittedPriorResults, + executeCoachTool, +} from "@/lib/ai/coach/tools/executor"; +import { renderPriorResultRefs } from "@/lib/ai/coach/chat-request-builder"; +import { + COACH_TOOL_DEFS, + COACH_TOOL_NAMES, + SHOW_RESULT_TOOL_NAME, +} from "@/lib/ai/coach/tools/definitions"; +import { createResultRefAllocator } from "@/lib/ai/coach/results/refs"; +import type { PriorResultTurn } from "@/lib/ai/coach/results/refs"; + +const REACH: CoachHistoryReach = { window: "last30days", days: LIMIT_DAYS }; + +function storedTable(ref: string, window: CoachScopeWindow) { + return { + ref, + source: { + tool: "get_metric_table" as const, + domain: "bp" as const, + window, + period: "current" as const, + }, + shape: "timeSeries" as const, + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: 3, + chartKind: null, + displayed: true, + }; +} + +const PRIOR: PriorResultTurn[] = [ + { + messageId: "m-earlier", + turnIndex: 1, + results: [storedTable("r1", "allTime"), storedTable("r2", "last7days")], + }, +]; + +/** + * One or more argument sets per tool, each the widest the schema allows. + * Every name in `COACH_TOOL_DEFS` must have an entry. + */ +const CASES: Record>> = { + get_metric_series: [ + { metric: "bp", window: "allTime" }, + { metric: "hrv", window: "lastYear" }, + ], + get_glucose_panel: [{ window: "allTime" }], + get_sleep: [{ window: "allTime" }], + get_medication_compliance: [{ window: "allTime" }], + get_labs: [{}], + get_illness_recovery: [{}], + get_workouts: [{ window: "allTime" }], + get_cycle: [{}], + get_correlations: [{}], + get_metric_table: [ + { metric: "bp", window: "allTime" }, + { metric: "weight", window: "last30days", period: "yearAgo" }, + { metric: "weight", window: "last7days", period: "previous" }, + { metric: "mood", window: "lastYear", granularity: "day" }, + ], + [SHOW_RESULT_TOOL_NAME]: [{ ref: "m1.r1" }, { ref: "m1.r2" }], +}; + +const FULL_SECTIONS: Record = { + bloodPressure: { aggregate: { avgSys30: 128 } }, + heartRateVariability: { aggregate: { avg30: 41 } }, + glucose: { unit: "mg/dL", byContext: {} }, + sleep: { recent: [] }, + compliance: { rate: 0.9 }, + workouts: { recent: [] }, + labs: { recent: [{ name: "LDL", analyte: "ldl" }] }, + illness: { restMode: false }, + derived: { READINESS: { value: 60 } }, + cycle: { phase: "luteal" }, + scope: { sources: ["bp", "weight", "mood", "hrv", "sleep"] }, +}; + +async function runCase( + name: string, + args: Record, +): Promise<{ reason?: string; reads: Read[] }> { + reads.length = 0; + const result = await executeCoachTool({ + userId: "u1", + name, + rawArguments: JSON.stringify(args), + fallbackWindow: "allTime", + reach: REACH, + turn: { + conversationId: "c1", + locale: "en", + priorResults: PRIOR, + refs: createResultRefAllocator(), + now: NOW, + }, + }); + return { reason: result.reason, reads: [...reads] }; +} + +describe("every Coach tool honours the lookback limit", () => { + beforeEach(() => { + vi.useFakeTimers({ now: NOW, toFake: ["Date"] }); + }); + + it("has a case for every tool the model is offered", () => { + const offered = COACH_TOOL_DEFS.map((def) => def.name).sort(); + expect(offered.length).toBeGreaterThan(0); + expect(offered).toEqual( + [...COACH_TOOL_NAMES, SHOW_RESULT_TOOL_NAME].sort(), + ); + expect(Object.keys(CASES).sort()).toEqual(offered); + }); + + for (const [label, present] of [ + ["with every section present", FULL_SECTIONS], + ["with nothing in the window (the empty-read path)", {}], + ] as const) { + it(`reads nothing older than the limit, ${label}`, async () => { + sections = present; + let checkedReads = 0; + let refusals = 0; + for (const [name, argSets] of Object.entries(CASES)) { + for (const args of argSets) { + const outcome = await runCase(name, args); + const where = `${name} ${JSON.stringify(args)}`; + const bad = outcome.reads.filter((read) => !read.ok); + expect(bad, `${where} read past the limit`).toEqual([]); + if (outcome.reads.length === 0) { + // Nothing read: only an explicit refusal is an answer. + expect(outcome.reason, `${where} made no read`).toBe( + "outside_reach", + ); + refusals += 1; + } + checkedReads += outcome.reads.length; + } + } + expect(checkedReads).toBeGreaterThan(0); + // The refusals are the cycle tool (it predicts from every cycle), the + // stored all-time table, and the year-ago and previous-period tables + // that do not fit into 30 days. + expect(refusals).toBeGreaterThan(0); + }); + } +}); + +describe("earlier tables answer to the lookback limit", () => { + beforeEach(() => { + vi.useFakeTimers({ now: NOW, toFake: ["Date"] }); + }); + + it("names no earlier table whose range lies beyond the limit", async () => { + // The context line carries the table's window and row count; an + // all-time table stored before the limit was set must not be listed. + const admitted = await admittedPriorResults({ + userId: "u1", + prefs: { excludeMetrics: [] }, + scope: undefined, + reach: REACH, + prior: PRIOR, + }); + const refs = admitted.flatMap((turn) => turn.results.map((r) => r.ref)); + expect(refs).toEqual(["r2"]); + const context = renderPriorResultRefs(admitted); + expect(context).toContain("last7days"); + expect(context).not.toContain("allTime"); + }); + + it("drops an earlier year-ago table that does not fit", async () => { + const yearAgo = { + ...storedTable("r3", "last7days"), + source: { ...storedTable("r3", "last7days").source, period: "yearAgo" }, + } as PriorResultTurn["results"][number]; + const admitted = await admittedPriorResults({ + userId: "u1", + prefs: { excludeMetrics: [] }, + scope: undefined, + reach: REACH, + prior: [{ messageId: "m-y", turnIndex: 3, results: [yearAgo] }], + }); + expect(admitted).toEqual([]); + }); +}); + +// ── The source ───────────────────────────────────────────────────────── + +const ROOT = join(__dirname, "../../../../.."); +const COACH_DIR = join(ROOT, "src/lib/ai/coach"); +const MCP_DIR = join(ROOT, "src/lib/mcp"); +const SOURCE_SNAPSHOT_FILE = "source-snapshot.ts"; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === "__tests__" || entry === "eval") continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (full.endsWith(".ts")) out.push(full); + } + return out; +} + +/** `source` without its comments, so a doc mention is not read as a call. */ +function code(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +/** The argument text of every call to `fn(` in `source`, parentheses balanced. */ +function callArguments(raw: string, fn: string): string[] { + const source = code(raw); + const out: string[] = []; + const pattern = new RegExp(`\\b${fn}\\s*\\(`, "g"); + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + // Skip the declaration itself. + const before = source.slice(Math.max(0, match.index - 20), match.index); + if (/function\s+$/.test(before)) continue; + let depth = 1; + let i = match.index + match[0].length; + const start = i; + while (i < source.length && depth > 0) { + if (source[i] === "(") depth += 1; + else if (source[i] === ")") depth -= 1; + i += 1; + } + out.push(source.slice(start, i - 1)); + } + return out; +} + +describe("the source hands the limit on", () => { + it("passes the limit in every dispatch branch of the executor", () => { + const source = readFileSync(join(COACH_DIR, "tools/executor.ts"), "utf8"); + const dispatch = source.slice( + source.indexOf("async function dispatchRead("), + ); + const branches = [ + ...dispatch.matchAll( + /case\s+"(get_[a-z_]+)"\s*:\s*return\s+[A-Za-z]+\s*\(([\s\S]*?)\)\s*;/g, + ), + ]; + expect(branches.length).toBe(COACH_TOOL_NAMES.length); + for (const [, name, args] of branches) { + expect(args, `${name} does not pass reach`).toMatch(/\breach\b/); + } + }); + + it("passes the limit to every Coach snapshot build", () => { + // `source-snapshot.ts` is the single-metric build only MCP asks for + // (`sourceSnapshot: true`); MCP reads without the Coach's limit, so that + // file is checked the other way round below. + const calls = walk(COACH_DIR) + .filter((file) => !file.endsWith(SOURCE_SNAPSHOT_FILE)) + .flatMap((file) => { + const source = readFileSync(file, "utf8"); + return ["buildCoachSnapshot", "resolveSnapshotPrelude"].flatMap((fn) => + callArguments(source, fn).map((args) => ({ + file: `${file.slice(ROOT.length + 1)} ${fn}`, + args, + })), + ); + }); + expect( + calls.filter((c) => c.file.endsWith("resolveSnapshotPrelude")).length, + ).toBeGreaterThan(0); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.args, `${call.file} builds without a limit`).toMatch( + /\breach\b/, + ); + } + }); + + it("keeps the MCP single-metric build unlimited", () => { + const source = readFileSync(join(COACH_DIR, SOURCE_SNAPSHOT_FILE), "utf8"); + const calls = ["buildCoachSnapshot", "resolveSnapshotPrelude"].flatMap( + (fn) => callArguments(source, fn), + ); + expect(calls.length).toBeGreaterThan(0); + for (const args of calls) expect(args).not.toMatch(/\breach\b/); + }); + + it("passes the limit when it lists earlier tables for the model", () => { + const calls = walk(COACH_DIR).flatMap((file) => + callArguments(readFileSync(file, "utf8"), "admittedPriorResults"), + ); + expect(calls.length).toBeGreaterThan(0); + for (const args of calls) expect(args).toMatch(/\breach\b/); + }); + + it("passes the limit from the chat loop and nowhere from MCP", () => { + const loop = callArguments( + readFileSync(join(COACH_DIR, "tools/loop.ts"), "utf8"), + "executeCoachTool", + ); + expect(loop.length).toBeGreaterThan(0); + for (const args of loop) expect(args).toMatch(/\breach\b/); + + const mcp = walk(MCP_DIR).flatMap((file) => + callArguments(readFileSync(file, "utf8"), "executeCoachTool"), + ); + expect(mcp.length).toBeGreaterThan(0); + for (const args of mcp) expect(args).not.toMatch(/\breach\b/); + }); +}); diff --git a/src/lib/ai/coach/__tests__/coach-history-reach.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach.test.ts new file mode 100644 index 000000000..a947c294f --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-history-reach.test.ts @@ -0,0 +1,229 @@ +/** + * The Coach lookback limit, piece by piece: the window arithmetic, the + * numbers the settings text states, the probe's `outside_reach` (which names + * no figure), the rule the model is given for it, and the DATA INVENTORY row. + * The executor-wide guard is `coach-history-reach-guard.test.ts`; the real + * readers against Postgres are `tests/integration/coach-history-reach.test.ts`. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const groupBy = vi.fn(); +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + groupBy: (args: unknown) => groupBy(args), + findFirst: (args: unknown) => findFirst(args), + }, + }, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: () => Promise.resolve("UTC"), +})); + +import { + COACH_ALL_TIME_TABLE_DAYS, + COACH_DAILY_DETAIL_DAYS, + COACH_HISTORY_WINDOWS, + HISTORY_REACH_DAYS, + UNBOUNDED_REACH, + capDays, + clampWindow, + fitsReach, + lookbackText, + reachCacheToken, + reachFromPrefs, + withinReach, +} from "@/lib/ai/coach/history-reach"; +import { windowToDays } from "@/lib/ai/coach/snapshot-series"; +import { resolveTableRange } from "@/lib/ai/coach/results/metric-table-tool"; +import { snapshotCacheKey } from "@/lib/ai/coach/snapshot-cache"; +import { coachDefaultWindowEnum } from "@/lib/validations/coach-prefs"; +import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum"; +import { resolveEmptyRead } from "@/lib/ai/coach/tools/availability"; +import { + renderDataInventory, + type CoachDataInventory, +} from "@/lib/ai/coach/tools/inventory"; + +const NOW = new Date("2026-10-04T12:00:00Z"); +const NINETY = reachFromPrefs({ defaultWindow: "last90days" }); + +describe("the lookback arithmetic", () => { + it("offers exactly the stored enum, narrowest first, all time last", () => { + expect([...COACH_HISTORY_WINDOWS]).toEqual(coachDefaultWindowEnum.options); + expect(COACH_HISTORY_WINDOWS.at(-1)).toBe("allTime"); + expect(reachFromPrefs({ defaultWindow: "allTime" })).toEqual( + UNBOUNDED_REACH, + ); + expect(reachFromPrefs(null)).toEqual(UNBOUNDED_REACH); + }); + + it("clamps a wider window to the limit and leaves a narrower one", () => { + expect(clampWindow("allTime", NINETY)).toBe("last90days"); + expect(clampWindow("lastYear", NINETY)).toBe("last90days"); + expect(clampWindow("last7days", NINETY)).toBe("last7days"); + expect(clampWindow("allTime", UNBOUNDED_REACH)).toBe("allTime"); + }); + + it("shortens a fixed window and drops a block that does not fit", () => { + expect(capDays(180, NINETY)).toBe(90); + expect(capDays(30, NINETY)).toBe(30); + expect(capDays(180, UNBOUNDED_REACH)).toBe(180); + expect(fitsReach(42, NINETY)).toBe(true); + expect(fitsReach(365, NINETY)).toBe(false); + expect(fitsReach(10_000, UNBOUNDED_REACH)).toBe(true); + expect( + withinReach(new Date(NOW.getTime() - 89 * 86_400_000), NINETY, NOW), + ).toBe(true); + expect( + withinReach(new Date(NOW.getTime() - 91 * 86_400_000), NINETY, NOW), + ).toBe(false); + }); + + it("keys a limited snapshot apart and leaves an unlimited key as it was", () => { + const scope = { window: "last30days" as const, sources: ["bp" as const] }; + expect(snapshotCacheKey("u1", scope)).toBe("u1|last30days|bp"); + expect(snapshotCacheKey("u1", scope, UNBOUNDED_REACH)).toBe( + "u1|last30days|bp", + ); + expect(snapshotCacheKey("u1", scope, NINETY)).toBe( + "u1|last30days|bp|reach90", + ); + expect(reachCacheToken(undefined)).toBe(""); + }); +}); + +describe("the numbers the settings text states", () => { + it("are the numbers the readers use", () => { + for (const window of ["last7days", "last30days", "last90days"] as const) { + expect(HISTORY_REACH_DAYS[window]).toBe(windowToDays(window)); + } + expect(HISTORY_REACH_DAYS.lastYear).toBe(windowToDays("lastYear")); + // All time reads a year in daily detail ... + expect(windowToDays("allTime")).toBe(COACH_DAILY_DETAIL_DAYS); + // ... and an all-time table reaches back the stated number of years. + const range = resolveTableRange({ + window: "allTime", + period: "current", + timeZone: "UTC", + now: NOW, + }); + const spanDays = + Math.floor((NOW.getTime() - range.from.getTime()) / 86_400_000) + 1; + expect(spanDays).toBe(COACH_ALL_TIME_TABLE_DAYS); + }); + + it("phrases each option with those numbers", () => { + expect(lookbackText("last90days")).toEqual({ + optionParams: { days: 90 }, + detailKey: "detailDays", + detailParams: { days: 90 }, + }); + expect(lookbackText("lastYear").detailParams).toEqual({ months: 12 }); + expect(lookbackText("allTime").detailParams).toEqual({ + dailyMonths: 12, + years: 10, + labMonths: 12, + }); + }); +}); + +describe("a history beyond the limit", () => { + beforeEach(() => { + groupBy.mockReset(); + findFirst.mockReset(); + }); + + it("is reported as outside_reach with no figure from it", async () => { + groupBy.mockResolvedValue([]); + findFirst.mockResolvedValue({ id: "old-row" }); + const result = await resolveEmptyRead({ + userId: "u1", + domain: "weight", + subject: { kind: "measurement", types: ["WEIGHT"] }, + searchedWindow: "last90days", + reach: NINETY, + now: NOW, + }); + expect(result).toEqual({ + present: false, + reason: "outside_reach", + searchedWindow: "last90days", + }); + // The in-limit probe asked only for rows inside the limit; the existence + // check asked only for rows before it. + const groupWhere = groupBy.mock.calls[0][0].where; + expect(groupWhere.measuredAt.gte.getTime()).toBe( + NOW.getTime() - 90 * 86_400_000, + ); + const firstWhere = findFirst.mock.calls[0][0].where; + expect(firstWhere.measuredAt.lt.getTime()).toBe( + NOW.getTime() - 90 * 86_400_000, + ); + }); + + it("is plain no_data when nothing older exists either", async () => { + groupBy.mockResolvedValue([]); + findFirst.mockResolvedValue(null); + const result = await resolveEmptyRead({ + userId: "u1", + domain: "weight", + subject: { kind: "measurement", types: ["WEIGHT"] }, + searchedWindow: "last90days", + reach: NINETY, + now: NOW, + }); + expect(result.reason).toBe("no_data"); + }); + + it("is not looked for without a limit (MCP, the default)", async () => { + groupBy.mockResolvedValue([]); + const result = await resolveEmptyRead({ + userId: "u1", + domain: "weight", + subject: { kind: "measurement", types: ["WEIGHT"] }, + searchedWindow: "last90days", + now: NOW, + }); + expect(result.reason).toBe("no_data"); + expect(findFirst).not.toHaveBeenCalled(); + expect(groupBy.mock.calls[0][0].where.measuredAt).toBeUndefined(); + }); +}); + +describe("what the model is told", () => { + it("gives outside_reach its own rule, in English and German", () => { + for (const locale of ["en", "de"] as const) { + const text = buildToolModeAddendum(locale); + expect(text).toMatch(/"outside_reach"|„outside_reach"/); + } + expect(buildToolModeAddendum("en")).toContain("Coach settings"); + expect(buildToolModeAddendum("de")).toContain("Coach-Einstellungen"); + }); + + it("marks a domain beyond the limit in the inventory without figures", () => { + const inventory: CoachDataInventory = { + entries: [ + { + tool: "get_metric_series", + metric: "weight", + domain: "weight", + present: false, + availability: { state: "outside_reach", reachableWithWindow: null }, + }, + ], + restMode: false, + cycleEnabled: false, + window: "last90days", + lookbackLimit: "last90days", + probeScope: { window: "last90days" }, + }; + const text = renderDataInventory(inventory); + expect(text).toContain("Lookback limit: last90days."); + const line = text.split("\n").find((l) => l.startsWith("- weight:")); + expect(line).toBe( + '- weight: BEYOND LOOKBACK → get_metric_series (metric:"weight")', + ); + }); +}); diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts index 83132cc46..05a39557d 100644 --- a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts @@ -304,3 +304,42 @@ describe("deriveTension (pure)", () => { expect(fired!.clinicalOverride).toBe(true); }); }); + +describe("buildDerivedSnapshotBlock under the lookback limit", () => { + it("leaves out a metric whose own window does not fit, never shortening it", async () => { + // Readiness reads 30 days. Under a 14-day limit it is left out rather + // than computed over 14 days, where it would disagree with the ring. + compute.mockImplementation(async (args?: { metric?: string }) => + args?.metric === "READINESS" || args?.metric === "STRESS_SCORE" + ? ok({ score: 60, band: "yellow" }) + : insufficient, + ); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW, "UTC", { + window: "last30days", + days: 14, + }); + const computed = compute.mock.calls.map( + ([args]) => (args as { metric: string }).metric, + ); + expect(computed).not.toContain("READINESS"); + expect(computed).not.toContain("SLEEP_SCORE"); + expect(computed).not.toContain("VASCULAR_AGE_DELTA"); + expect(computed).toContain("STRESS_SCORE"); + for (const [args] of compute.mock.calls) { + const windowDays = (args as { windowDays?: number }).windowDays; + expect(windowDays === undefined || windowDays >= 14).toBe(true); + } + expect(block?.READINESS).toBeUndefined(); + expect(block?.STRESS_SCORE).toBeDefined(); + }); + + it("computes nothing under a 7-day limit, where no metric fits", async () => { + compute.mockResolvedValue(ok({ score: 60, band: "yellow" })); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW, "UTC", { + window: "last7days", + days: 7, + }); + expect(compute).not.toHaveBeenCalled(); + expect(block).toBeNull(); + }); +}); diff --git a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts index 4836ac307..ab8fcbe7d 100644 --- a/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/glp1-snapshot.test.ts @@ -332,3 +332,66 @@ describe("buildGlp1SnapshotBlock injection days in the user's zone", () => { }); }); }); + +describe("buildGlp1SnapshotBlock under the lookback limit", () => { + it("leaves dose changes before the limit out and keeps the current dose", async () => { + const now = new Date("2026-10-04T12:00:00.000Z"); + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + doseChanges: [ + { + doseValue: 2.5, + doseUnit: "mg", + effectiveFrom: new Date("2026-06-01T00:00:00.000Z"), + note: "Nausea in the first week", + }, + { + doseValue: 5, + doseUnit: "mg", + effectiveFrom: new Date("2026-08-01T00:00:00.000Z"), + note: "Stepped up after review", + }, + ], + }), + ]); + const out = await buildGlp1SnapshotBlock("user-1", now, "UTC", { + window: "last30days", + days: 30, + }); + const med = out?.medications[0]; + // The current dose is therapy state, not history. + expect(med?.currentDose).toMatchObject({ value: 5, since: "2026-08-01" }); + // Both changes lie before the 30-day floor: neither they nor their + // notes reach the prompt. + expect(med?.doseHistory).toEqual([]); + expect(JSON.stringify(out)).not.toContain("Nausea"); + expect(JSON.stringify(out)).not.toContain("Stepped up"); + }); + + it("keeps the dose changes inside the limit", async () => { + const now = new Date("2026-10-04T12:00:00.000Z"); + prismaMock.medication.findMany.mockResolvedValue([ + fakeMedication({ + doseChanges: [ + { + doseValue: 5, + doseUnit: "mg", + effectiveFrom: new Date("2026-08-01T00:00:00.000Z"), + note: null, + }, + { + doseValue: 7.5, + doseUnit: "mg", + effectiveFrom: new Date("2026-09-20T00:00:00.000Z"), + note: null, + }, + ], + }), + ]); + const out = await buildGlp1SnapshotBlock("user-1", now, "UTC", { + window: "last30days", + days: 30, + }); + expect(out?.medications[0].doseHistory.map((d) => d.value)).toEqual([7.5]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts index f5f0cc3c4..78545196e 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({ import { HARD_CAP, runCoachToolLoop } from "@/lib/ai/coach/tools/loop"; import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; type Call = { id: string; name: string; arguments: string }; @@ -44,6 +45,7 @@ const baseArgs = { system: "sys", messages: [{ role: "user" as const, content: "q" }], tools: COACH_TOOL_DEFS, + reach: UNBOUNDED_REACH, }; beforeEach(() => { diff --git a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts index ed35284c4..d932c7faf 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts @@ -32,6 +32,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({ import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop"; import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; const baseArgs = { userId: "u1", @@ -39,6 +40,7 @@ const baseArgs = { system: "sys", messages: [{ role: "user" as const, content: "how did I sleep?" }], tools: COACH_TOOL_DEFS, + reach: UNBOUNDED_REACH, }; describe("coach tool loop — which results reach the grounding guard", () => { diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 973c1d751..05200ad18 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -26,6 +26,7 @@ import { MAX_ROUNDS, } from "@/lib/ai/coach/tools/loop"; import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; function completion(opts: { content: string; @@ -53,6 +54,7 @@ const baseArgs = { system: "sys", messages: [{ role: "user" as const, content: "how is my bp?" }], tools: COACH_TOOL_DEFS, + reach: UNBOUNDED_REACH, }; describe("runCoachToolLoop", () => { diff --git a/src/lib/ai/coach/__tests__/workout-evidence-builder.test.ts b/src/lib/ai/coach/__tests__/workout-evidence-builder.test.ts index b64eb595f..824868ead 100644 --- a/src/lib/ai/coach/__tests__/workout-evidence-builder.test.ts +++ b/src/lib/ai/coach/__tests__/workout-evidence-builder.test.ts @@ -73,11 +73,14 @@ describe("workout evidence builder", () => { durationSec: 2400, storedSamples: [{ tSec: 0, mean: 120 }], }); + // No limit passed: the builder hands the unlimited reach on, and + // `buildSportContext` decides whether its 180 days fit. expect(buildSportContext).toHaveBeenCalledWith( "u1", "running", { workouts: ["APPLE_HEALTH"] }, "w1", + { window: "allTime", days: null }, ); expect(buildEvidence).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/ai/coach/correlations-snapshot.ts b/src/lib/ai/coach/correlations-snapshot.ts index 489252c76..e3f4c591b 100644 --- a/src/lib/ai/coach/correlations-snapshot.ts +++ b/src/lib/ai/coach/correlations-snapshot.ts @@ -18,6 +18,7 @@ * Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null * return (no surviving driver / read hiccup) attaches nothing. */ +import { UNBOUNDED_REACH, type CoachHistoryReach } from "./history-reach"; import type { Locale } from "@/lib/i18n/config"; import { readCoachCorrelations, @@ -53,8 +54,10 @@ export async function buildCorrelationsSnapshotBlock( * the locale, so it says it. */ locale: Locale, + /** The Coach's lookback limit, applied to the scan window. */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { - const result = await readCoachCorrelations(userId, locale); + const result = await readCoachCorrelations(userId, locale, { reach }); if (!result.present || !result.drivers || result.drivers.length === 0) { return null; } diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 6d3fc75d1..54a1fcd2f 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -22,6 +22,11 @@ import { type BaselineProfile, } from "@/lib/insights/derived"; import { prisma } from "@/lib/db"; +import { + UNBOUNDED_REACH, + fitsReach, + type CoachHistoryReach, +} from "./history-reach"; /** * Trailing window the recovery dedup probes for a WHOOP-native row — matches @@ -30,6 +35,28 @@ import { prisma } from "@/lib/db"; */ const RECOVERY_DEDUP_WINDOW_DAYS = 14; +/** + * The window each engine reads (its own `DEFAULT_WINDOW_DAYS`). Under a + * lookback limit a metric whose window does not fit inside it is left out, + * never computed over a shortened window: a 7-day readiness would disagree + * with the 30-day ring the dashboard shows under the same name. + */ +const DERIVED_DEFAULT_WINDOW_DAYS: Readonly< + Partial> +> = { + READINESS: 30, + RECOVERY_SCORE: 14, + STRESS_SCORE: 14, + STRAIN_SCORE: 14, + SLEEP_SCORE: 30, + HRV_BALANCE: 30, + FITNESS_AGE: 180, + VASCULAR_AGE_DELTA: 365, +}; + +/** The coincident-deviation flag's own default window. */ +const COINCIDENT_DEFAULT_WINDOW_DAYS = 30; + /** The high-signal derived metrics worth a Coach prompt slot. The vitals * baseline is omitted — the per-vital aggregate block already carries those * numbers; here we add the composites + scores the aggregates can't express. */ @@ -226,8 +253,15 @@ export async function buildDerivedSnapshotBlock( profile: BaselineProfile, now: Date, tz?: string, + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { const block: Record = {}; + // Every engine keeps its own window; one that does not fit is not run. + const fits = (metric: DerivedMetricId) => + fitsReach( + DERIVED_DEFAULT_WINDOW_DAYS[metric] ?? COINCIDENT_DEFAULT_WINDOW_DAYS, + reach, + ); // v1.21.0 (C3 / D3) — the coincident-deviation flag, fired-only. Computed // off the one shared profile alongside the scores; fail-soft to null so a @@ -235,21 +269,29 @@ export async function buildDerivedSnapshotBlock( // FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping // the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping // matches the user's calendar day, not UTC's. - const coincidentPromise = computeCoincidentDeviation(userId, profile, { - now, - ...(tz ? { tz } : {}), - }).catch(() => null); + // Its 30-day baseline is left out, like any block, under a shorter limit. + const coincidentPromise = fitsReach(COINCIDENT_DEFAULT_WINDOW_DAYS, reach) + ? computeCoincidentDeviation(userId, profile, { + now, + ...(tz ? { tz } : {}), + }).catch(() => null) + : Promise.resolve(null); // The metrics are independent passthrough reads off the one shared profile — // no ordering dependency — so compute them concurrently. Per-metric fault // isolation: a transient failure on one must never sink the whole Coach turn, // so each compute resolves to null on throw rather than rejecting the batch. const computed = await Promise.all( - SNAPSHOT_METRICS.map(async (metric) => { + SNAPSHOT_METRICS.filter(fits).map(async (metric) => { try { return { metric, - derived: await computeDerivedMetric({ metric, userId, profile, now }), + derived: await computeDerivedMetric({ + metric, + userId, + profile, + now, + }), }; } catch { return { metric, derived: null }; @@ -302,6 +344,8 @@ export async function buildDerivedSnapshotBlock( source: "WHOOP", deletedAt: null, measuredAt: { + // Both lines exist only when their windows fit, so this 14-day + // probe lies inside the limit too. gte: new Date( now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000, ), diff --git a/src/lib/ai/coach/eval/run-case.ts b/src/lib/ai/coach/eval/run-case.ts index 2486bb67c..65ad7d65e 100644 --- a/src/lib/ai/coach/eval/run-case.ts +++ b/src/lib/ai/coach/eval/run-case.ts @@ -25,6 +25,7 @@ * This mirrors the route exactly (`coach-prose-grounding-no-tools.test.ts`). */ import type { Locale } from "@/lib/i18n/config"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; import type { AiMessage, AiToolCall, @@ -113,6 +114,8 @@ export async function runRealCase(args: { // folded into the system prompt by the judge so a no-tools provider still // has the context. The loop tolerates an empty toolCalls (no-tools path). tools: [], + // An eval case is a fixed transcript, not a person with a setting. + reach: UNBOUNDED_REACH, temperature, maxTokens, }); diff --git a/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts b/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts index 35aeb355d..048be7aea 100644 --- a/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts +++ b/src/lib/ai/coach/follow-ups/__tests__/derive.test.ts @@ -107,6 +107,19 @@ describe("deriveFollowUps — the rules", () => { }); }); + it("offers no chip that would read past the Coach's lookback limit", () => { + const limited = (defaultWindow: "last30days" | "last90days") => + derive({ + history: LONG_HISTORY, + prefs: { ...DEFAULT_COACH_PREFS, defaultWindow }, + }).map((c) => c.kind); + // 30 days: the period before needs 60, a year ago 395, the wider window + // is 90 days. None fits. + expect(limited("last30days")).toEqual([]); + // 90 days: the period before (60 days) and the 90-day window fit. + expect(limited("last90days")).toEqual(["previous_period", "widen_window"]); + }); + it("offers a year ago and a wider window only when the history reaches back", () => { expect(derive({ history: LONG_HISTORY }).map((c) => c.kind)).toEqual([ "previous_period", diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts index 34f596af5..7334898ef 100644 --- a/src/lib/ai/coach/follow-ups/derive.ts +++ b/src/lib/ai/coach/follow-ups/derive.ts @@ -56,8 +56,15 @@ import { buildFollowUp, isTableMetricDomain, numberFollowUps, + widerWindow, } from "./catalog"; import type { FollowUpProposal } from "./parse-sentinel"; +import { + exceedsReach, + fitsReach, + reachFromPrefs, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; /** A discovered pair `get_correlations` returned this turn, as scope sources. */ export interface CorrelationPair { @@ -86,6 +93,30 @@ const WINDOW_DAYS: Partial> = { lastYear: 365, }; +/** + * True when answering `chip` stays inside the lookback limit: the period + * before needs twice the window, a year ago a year more than it, a wider + * window must itself fit. + */ +function chipWithinReach(chip: Candidate, reach: CoachHistoryReach): boolean { + const window = chip.anchor?.window; + if (!window) return true; + if (exceedsReach(window, reach)) return false; + const days = WINDOW_DAYS[window]; + switch (chip.kind) { + case "previous_period": + return days !== undefined && fitsReach(2 * days, reach); + case "year_ago": + return days !== undefined && fitsReach(days + 365, reach); + case "widen_window": { + const wider = widerWindow(window); + return wider !== null && !exceedsReach(wider, reach); + } + default: + return true; + } +} + /** Whether the pref lets chips be offered. Absent means on. */ export function followUpChipsEnabled(prefs: CoachPrefs): boolean { return prefs.followUpChips !== false; @@ -254,10 +285,14 @@ export function deriveFollowUps(args: { } } - // Deduplicate by kind and domain, first wins. + // Deduplicate by kind and domain, first wins. A chip that would read past + // the lookback limit is not offered: it could only be answered with + // "that lies beyond your limit". + const reach = reachFromPrefs(args.prefs); const key = (chip: Candidate) => `${chip.kind}:${chip.anchor?.domain ?? ""}`; const unique = new Map(); for (const chip of candidates) { + if (!chipWithinReach(chip, reach)) continue; if (!unique.has(key(chip))) unique.set(key(chip), chip); } diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 4256e827d..2edec8159 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -18,6 +18,12 @@ * Block omitted entirely when the user has no GLP-1 medications, so * 99% of users never pay the read cost. */ +import { + UNBOUNDED_REACH, + laterOfFloor, + reachFloor, + type CoachHistoryReach, +} from "./history-reach"; import { prisma } from "@/lib/db"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { readNote } from "@/lib/crypto/note-cipher"; @@ -218,7 +224,15 @@ export async function buildGlp1SnapshotBlock( userId: string, now: Date = new Date(), tz: string = DEFAULT_TIMEZONE, + /** + * The Coach's lookback limit. The therapy plan (current dose, schedule, + * pens) is current state and stays; the last injection, the side-effect + * tags and the dose history (each change with its note) are entries and + * are read from inside the limit only. + */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { + const floor = reachFloor(reach, now); // Test environments mock parts of prisma (only `measurement` + // `moodEntry`) and leave `medication` undefined. Treat the absence // as "no GLP-1 therapy" so the helper silently bows out — matching @@ -242,7 +256,7 @@ export async function buildGlp1SnapshotBlock( inventoryItems: { orderBy: { createdAt: "asc" } }, inventoryEvents: { orderBy: { occurredAt: "asc" } }, intakeEvents: { - where: { takenAt: { not: null } }, + where: { takenAt: floor ? { not: null, gte: floor } : { not: null } }, orderBy: { takenAt: "desc" }, take: 1, select: { @@ -259,7 +273,11 @@ export async function buildGlp1SnapshotBlock( // the curated GLP-1 tag list. We pull once per user even if there are // multiple GLP-1 meds (rare) and attribute the same recent symptoms // to each — the Coach can disambiguate from context. - const moodCutoff = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const moodCutoff = laterOfFloor( + new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000), + reach, + now, + ); const moods: RawMoodEntry[] = await prisma.moodEntry.findMany({ // v1.7.0 sync — exclude tombstoned rows. where: { userId, deletedAt: null, moodLoggedAt: { gte: moodCutoff } }, @@ -292,7 +310,12 @@ export async function buildGlp1SnapshotBlock( const genericSafe = sanitizeForPrompt(generic, 80); const latestChange = med.doseChanges[med.doseChanges.length - 1] ?? null; - const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => { + // A dose change is an entry like any other: one made before the limit + // is left out with its note. The dose in force stays as `currentDose`. + const changesInReach = med.doseChanges.filter( + (dc) => floor === null || dc.effectiveFrom.getTime() >= floor.getTime(), + ); + const doseHistory: DoseHistoryEntry[] = changesInReach.map((dc) => { const decryptedNote = readNote(dc.noteEncrypted, dc.note); return { value: dc.doseValue, diff --git a/src/lib/ai/coach/history-reach-read.ts b/src/lib/ai/coach/history-reach-read.ts new file mode 100644 index 000000000..a3cf6cafb --- /dev/null +++ b/src/lib/ai/coach/history-reach-read.ts @@ -0,0 +1,18 @@ +/** + * The person's Coach lookback limit, read from their saved preferences. Kept + * apart from `history-reach.ts` so that module stays free of database access + * and can be imported by client code (the settings picker). + */ +import { prisma } from "@/lib/db"; +import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; +import { reachFromPrefs, type CoachHistoryReach } from "./history-reach"; + +export async function readCoachReach( + userId: string, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { coachPrefsJson: true }, + }); + return reachFromPrefs(parseCoachPrefs(row?.coachPrefsJson)); +} diff --git a/src/lib/ai/coach/history-reach.ts b/src/lib/ai/coach/history-reach.ts new file mode 100644 index 000000000..571a36ea1 --- /dev/null +++ b/src/lib/ai/coach/history-reach.ts @@ -0,0 +1,200 @@ +/** + * How far back the Coach may look: the user's `coachPrefs.defaultWindow` + * read as a LIMIT, not as a starting point. + * + * Before it became a limit, the saved window only seeded the snapshot and the default of + * each tool call; every tool accepted its own `window` up to `allTime`, the + * availability probe reported out-of-window history with counts and means, + * and several snapshot blocks read fixed windows (labs 12 months, glucose + * panel 30 days, correlations 180 days) whatever the setting said. The + * setting is now enforced everywhere the Coach reads: the snapshot, every + * tool, the probe, the inventory and the fixed windows. Each reader takes a + * `CoachHistoryReach` and either clamps its window to it (`clampWindow`, + * `capDays`) or leaves its block out when the block's own fixed window does + * not fit inside it (`fitsReach`). A block is never computed over a silently + * shortened window that would make it disagree with the page that shows it. + * + * What is NOT history and stays visible under any limit: the profile (age, + * sex, height), the current medication plan, an illness episode that is still + * ongoing, upcoming appointments, and what the person told the Coach (facts, + * plans, reminders). The settings text says so. + * + * `allTime` is no limit at all and leaves every reader exactly as it was. + * MCP clients never pass a reach: an MCP token reads with its own window + * arguments, and the Coach setting is a Coach setting. + */ +import type { CoachScopeWindow } from "./types"; +import type { + CoachDefaultWindow, + CoachPrefs, +} from "@/lib/validations/coach-prefs"; + +/** The lookback options, narrowest first. */ +export const COACH_HISTORY_WINDOWS: ReadonlyArray = [ + "last7days", + "last30days", + "last90days", + "lastYear", + "allTime", +]; + +/** Days each option reaches back; `null` is no limit. */ +export const HISTORY_REACH_DAYS: Readonly< + Record +> = { + last7days: 7, + last30days: 30, + last90days: 90, + lastYear: 365, + allTime: null, +}; + +export interface CoachHistoryReach { + /** The option the person chose. */ + window: CoachDefaultWindow; + /** Days the Coach may look back, or `null` for no limit. */ + days: number | null; +} + +/** No limit: the behaviour before the limit existed, and the default. */ +export const UNBOUNDED_REACH: CoachHistoryReach = Object.freeze({ + window: "allTime", + days: null, +}); + +export function reachFromPrefs( + prefs: Pick | null | undefined, +): CoachHistoryReach { + const window = prefs?.defaultWindow ?? "allTime"; + return { window, days: HISTORY_REACH_DAYS[window] }; +} + +export function isBounded(reach: CoachHistoryReach): boolean { + return reach.days !== null; +} + +const WINDOW_RANK: Readonly> = { + last7days: 0, + last30days: 1, + last90days: 2, + lastYear: 3, + allTime: 4, +}; + +/** The narrower of `window` and the reach. */ +export function clampWindow( + window: CoachScopeWindow, + reach: CoachHistoryReach, +): CoachScopeWindow { + return WINDOW_RANK[window] <= WINDOW_RANK[reach.window] + ? window + : reach.window; +} + +/** True when `window` reaches further back than the limit allows. */ +export function exceedsReach( + window: CoachScopeWindow, + reach: CoachHistoryReach, +): boolean { + return WINDOW_RANK[window] > WINDOW_RANK[reach.window]; +} + +/** A fixed window of `days`, shortened to the limit. */ +export function capDays(days: number, reach: CoachHistoryReach): number { + return reach.days === null ? days : Math.min(days, reach.days); +} + +/** + * True when a block whose own window is `horizonDays` long fits inside the + * limit. A block that does not fit is left out, never computed over fewer + * days than the page that shows it uses. + */ +export function fitsReach( + horizonDays: number, + reach: CoachHistoryReach, +): boolean { + return reach.days === null || horizonDays <= reach.days; +} + +/** The earliest instant the Coach may read, or `null` for no limit. */ +export function reachFloor( + reach: CoachHistoryReach, + now: Date = new Date(), +): Date | null { + return reach.days === null + ? null + : new Date(now.getTime() - reach.days * 86_400_000); +} + +/** True when `at` lies inside the limit. */ +export function withinReach( + at: Date, + reach: CoachHistoryReach, + now: Date = new Date(), +): boolean { + const floor = reachFloor(reach, now); + return floor === null || at.getTime() >= floor.getTime(); +} + +/** The later of `cutoff` and the limit's floor. */ +export function laterOfFloor( + cutoff: Date, + reach: CoachHistoryReach, + now: Date = new Date(), +): Date { + const floor = reachFloor(reach, now); + return floor !== null && floor.getTime() > cutoff.getTime() ? floor : cutoff; +} + +/** A short stable token for cache keys; empty for no limit. */ +export function reachCacheToken(reach: CoachHistoryReach | undefined): string { + return reach && reach.days !== null ? `|reach${reach.days}` : ""; +} + +// ── The numbers the settings text states ──────────────────────────────── +// +// The readers below import these, so the sentence the person reads and the +// window the Coach reads come from one place +// (`coach-history-reach.test.ts` pins it). + +/** Days of daily detail the snapshot reads at most (`windowToDays`). */ +export const COACH_DAILY_DETAIL_DAYS = 365; + +/** How far back an all-time table reaches, as monthly values. */ +export const COACH_ALL_TIME_TABLE_DAYS = 3_650; + +/** Months of lab results the Coach reads (`labs-snapshot.ts`). */ +export const COACH_LABS_LOOKBACK_MONTHS = 12; + +/** The i18n key and parameters describing one lookback option. */ +export function lookbackText(window: CoachDefaultWindow): { + optionParams: Record; + detailKey: "detailDays" | "detailMonths" | "detailAll"; + detailParams: Record; +} { + const days = HISTORY_REACH_DAYS[window]; + if (days === null) { + return { + optionParams: {}, + detailKey: "detailAll", + detailParams: { + dailyMonths: Math.round(COACH_DAILY_DETAIL_DAYS / 30.4), + years: Math.round(COACH_ALL_TIME_TABLE_DAYS / 365), + labMonths: COACH_LABS_LOOKBACK_MONTHS, + }, + }; + } + if (days >= 365) { + const months = Math.round(days / 30.4); + return { + optionParams: { months }, + detailKey: "detailMonths", + detailParams: { months }, + }; + } + return { + optionParams: { days }, + detailKey: "detailDays", + detailParams: { days }, + }; +} diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts index 37ce72d8b..ae6c97e09 100644 --- a/src/lib/ai/coach/illness-snapshot.ts +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -19,6 +19,17 @@ import { prisma } from "@/lib/db"; import { isIllnessEnabled } from "@/lib/illness/gate"; import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; +import { + BASELINE_WINDOW_DAYS, + PRE_ONSET_LOOKBACK_DAYS, +} from "@/lib/illness/correlation"; +import { + UNBOUNDED_REACH, + laterOfFloor, + reachFloor, + withinReach, + type CoachHistoryReach, +} from "./history-reach"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; @@ -131,8 +142,13 @@ const SYMPTOM_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; async function readRecentSymptoms( userId: string, now: Date, + reach: CoachHistoryReach, ): Promise { - const since = new Date(now.getTime() - SYMPTOM_WINDOW_MS); + const since = laterOfFloor( + new Date(now.getTime() - SYMPTOM_WINDOW_MS), + reach, + now, + ); const grouped = await prisma.symptomEvent.groupBy({ by: ["definitionId"], where: { userId, occurredAt: { gte: since, lte: now } }, @@ -181,8 +197,15 @@ async function readRecentSymptoms( export async function buildIllnessSnapshotBlock( userId: string, now: Date = new Date(), + /** + * The Coach's lookback limit. An episode that is still ongoing is current + * state and always shown (it carries Rest Mode); resolved episodes and + * symptoms are history and are read from inside the limit only. + */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { if (!(await isIllnessEnabled(userId))) return null; + const floor = reachFloor(reach, now); const [activeRows, resolvedRows, symptoms] = await Promise.all([ prisma.illnessEpisode.findMany({ @@ -203,7 +226,12 @@ export async function buildIllnessSnapshotBlock( }, }), prisma.illnessEpisode.findMany({ - where: { userId, deletedAt: null, resolvedAt: { not: null } }, + where: { + userId, + deletedAt: null, + resolvedAt: { not: null }, + ...(floor ? { onsetAt: { gte: floor } } : {}), + }, orderBy: { resolvedAt: "desc" }, take: RESOLVED_HISTORY_LIMIT, select: { @@ -215,7 +243,7 @@ export async function buildIllnessSnapshotBlock( laterality: true, }, }), - readRecentSymptoms(userId, now), + readRecentSymptoms(userId, now, reach), ]); if ( @@ -342,6 +370,12 @@ function compactFindings( export async function buildIllnessScores( userId: string, now: Date = new Date(), + /** + * The Coach's lookback limit. The scores compare the episode against a + * baseline read before its onset, so they are computed only when that + * baseline lies inside the limit too. + */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { if (!(await isIllnessEnabled(userId))) return null; @@ -385,6 +419,11 @@ export async function buildIllnessScores( // about); fall back to the most-recently-resolved for "last time" questions. const episode = active ?? resolved; if (!episode) return null; + const baselineStart = new Date( + episode.onsetAt.getTime() - + (PRE_ONSET_LOOKBACK_DAYS + BASELINE_WINDOW_DAYS) * 86_400_000, + ); + if (!withinReach(baselineStart, reach, now)) return null; const tz = user?.timezone ?? DEFAULT_TIMEZONE; const derived = await computeEpisodeCorrelation( diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 50c3ee109..90b9105ad 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -37,13 +37,19 @@ * is always owner-scoped and safe to read), so the block is `null` only when the * account has no recent readings. */ +import { + COACH_LABS_LOOKBACK_MONTHS, + UNBOUNDED_REACH, + laterOfFloor, + type CoachHistoryReach, +} from "./history-reach"; import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range"; import { resolveLabFields } from "@/lib/labs/serialise"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { prisma } from "@/lib/db"; /** Only readings within this many months enter the prompt. */ -const LOOKBACK_MONTHS = 12; +const LOOKBACK_MONTHS = COACH_LABS_LOOKBACK_MONTHS; /** Cap on distinct biomarkers in the block (newest reading first). */ const MAX_BIOMARKERS = 24; @@ -107,9 +113,12 @@ export interface CoachLabsBlock { export async function buildLabsSnapshotBlock( userId: string, now: Date = new Date(), + /** The Coach's lookback limit: the shorter of it and the 12 months wins. */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { - const cutoff = new Date(now); - cutoff.setMonth(cutoff.getMonth() - LOOKBACK_MONTHS); + const monthsBack = new Date(now); + monthsBack.setMonth(monthsBack.getMonth() - LOOKBACK_MONTHS); + const cutoff = laterOfFloor(monthsBack, reach, now); // Newest reading first; we keep only the first row seen per biomarker so the // block carries the MOST RECENT value per analyte. Bounded fetch: at most a diff --git a/src/lib/ai/coach/memory-snapshot.ts b/src/lib/ai/coach/memory-snapshot.ts index 9c262a8aa..0e0d585f1 100644 --- a/src/lib/ai/coach/memory-snapshot.ts +++ b/src/lib/ai/coach/memory-snapshot.ts @@ -40,6 +40,14 @@ import { import type { BaselineProfile } from "@/lib/insights/derived"; import type { Locale } from "@/lib/i18n/config"; import { buildCoachFactsBlock } from "./facts"; +import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + UNBOUNDED_REACH, + fitsReach, + isBounded, + withinReach, + type CoachHistoryReach, +} from "./history-reach"; import { buildCoachPlansBlock, buildCoachRemindersBlock, @@ -50,6 +58,31 @@ import { /** The period the rolling profile recalls — month is the high-signal beat. */ const MEMORY_PERIOD: NarrativePeriod = "month"; +/** + * How far back the narrative and the band memory reach: the prior month + * against the current one. A shorter lookback limit leaves both out; facts, + * plans and reminders are what the person told the Coach, not readings, and + * stay. + */ +const MEMORY_HISTORY_HORIZON_DAYS = 2 * 30; // two `PERIOD_DAYS.month` + +/** True when the history a narrative dated `dateKey` describes lies inside the limit. */ +function narrativeWithinReach( + dateKey: string, + reach: CoachHistoryReach, + now: Date, +): boolean { + if (!isBounded(reach)) return true; + // The narrative row's own day key, compared as its date, not a local day. + const end = dayKeyAsUtcMidnight(dateKey).getTime(); + if (!Number.isFinite(end)) return false; + return withinReach( + new Date(end - MEMORY_HISTORY_HORIZON_DAYS * 86_400_000), + reach, + now, + ); +} + /** Cap the recalled narrative so a verbose row cannot bloat the snapshot. */ const NARRATIVE_HEADLINE_CHARS = 600; /** Drivers are conservative one-liners; a handful is plenty of recall. */ @@ -145,12 +178,22 @@ export async function buildCoachMemoryBlock( * pipeline never writes for that user. */ locale: Locale, + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { + const historyAllowed = fitsReach(MEMORY_HISTORY_HORIZON_DAYS, reach); let priorNarrative: PriorNarrativeRecall | undefined; - // Sub-source 1: the latest period-narrative headline + driver recall. + // Sub-source 1: the latest period-narrative headline + driver recall. The + // row is whatever was last written, of any age; under a limit it counts + // only when the history it describes lies inside the limit. try { - const row = await readPeriodNarrative(userId, MEMORY_PERIOD, locale); - if (row && row.text.trim().length > 0) { + const row = historyAllowed + ? await readPeriodNarrative(userId, MEMORY_PERIOD, locale) + : null; + if ( + row && + row.text.trim().length > 0 && + narrativeWithinReach(row.dateKey, reach, now) + ) { const recall = recallNarrative(row); recall.drivers = recall.drivers.slice(0, MAX_RECALLED_DRIVERS); priorNarrative = recall; @@ -165,12 +208,14 @@ export async function buildCoachMemoryBlock( // parallel aggregation, no recompute beyond the one assembly call. const trendMemory: Record = {}; try { - const ctx = await buildPeriodNarrativeContext(userId, { - period: MEMORY_PERIOD, - now, - locale, - }); - if (ctx.status === "ready") { + const ctx = historyAllowed + ? await buildPeriodNarrativeContext(userId, { + period: MEMORY_PERIOD, + now, + locale, + }) + : null; + if (ctx?.status === "ready") { for (const transition of ctx.bandTransitions) { trendMemory[transition.type] = trendFromTransition(transition); } diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index e7d2fe1f6..b793deb6f 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -50,6 +50,7 @@ import { type CoachToolTurnContext, } from "@/lib/ai/coach/tools/executor"; import { createResultRefAllocator } from "../refs"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; import { findUnverifiedCoachNumbers } from "@/lib/ai/coach/coach-prose-grounding"; const NOW = new Date("2026-09-27T10:00:00Z"); @@ -544,6 +545,7 @@ describe("admittedPriorResults", () => { it("drops a metric the person excluded and keeps what no exclusion names", async () => { const out = await admittedPriorResults({ userId: "u1", + reach: UNBOUNDED_REACH, prefs: { excludeMetrics: ["steps"] }, scope: undefined, prior, @@ -556,6 +558,7 @@ describe("admittedPriorResults", () => { it("keeps to the conversation's scope", async () => { const out = await admittedPriorResults({ userId: "u1", + reach: UNBOUNDED_REACH, prefs: { excludeMetrics: [] }, scope: { sources: ["steps"] }, prior, @@ -572,6 +575,7 @@ describe("admittedPriorResults", () => { resolveModuleMap.mockResolvedValue({ mood: false }); const out = await admittedPriorResults({ userId: "u1", + reach: UNBOUNDED_REACH, prefs: { excludeMetrics: [] }, scope: undefined, prior: [ diff --git a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts index 3b4275694..780b1281f 100644 --- a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts +++ b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/ai/provider-runner", () => ({ import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop"; import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; import { createResultRefAllocator } from "../refs"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; function round(content: string, toolCalls?: unknown[]) { return { @@ -80,6 +81,7 @@ describe("runCoachToolLoop — result tables", () => { system: "sys", messages: [{ role: "user", content: "q" }], tools: COACH_TOOL_DEFS, + reach: UNBOUNDED_REACH, turn, onCallSettled: (_call, result) => settled.push(result), }); @@ -123,6 +125,7 @@ describe("runCoachToolLoop — result tables", () => { systemOnceTableShown: "sys+tables", messages: [{ role: "user", content: "q" }], tools: COACH_TOOL_DEFS, + reach: UNBOUNDED_REACH, turn: { conversationId: "c1", locale: "en", diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts index 1ea4b50a2..e29d7e56a 100644 --- a/src/lib/ai/coach/results/metric-table-tool.ts +++ b/src/lib/ai/coach/results/metric-table-tool.ts @@ -29,6 +29,7 @@ * Those summary figures, not the table, are what the reply's numbers are * checked against. */ +import { COACH_ALL_TIME_TABLE_DAYS } from "@/lib/ai/coach/history-reach"; import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import type { Locale } from "@/lib/i18n/config"; @@ -95,8 +96,8 @@ export const TABLE_SUMMARY_MAX_CHARS = 6_000; /** At most this many row values ride the summary. */ export const TABLE_SUMMARY_MAX_VALUES = 60; -/** The chart's "All" range: about ten years. */ -const ALL_TIME_DAYS = 3_650; +/** The chart's "All" range: about ten years (the settings text states it). */ +const ALL_TIME_DAYS = COACH_ALL_TIME_TABLE_DAYS; const WINDOW_DAYS: Readonly> = { last7days: 7, diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index 9131feb90..86d352c7d 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -50,6 +50,7 @@ import { type SelfContext, } from "@/lib/ai/coach/about-me"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; +import { readCoachReach } from "@/lib/ai/coach/history-reach-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { @@ -255,7 +256,10 @@ export async function deriveClarifyingQuestions( // ask about what the user actually tracks. Best-effort: a snapshot // failure must not cost the AI path, the prompt just stays // fields-only. - const snapshotJson = await buildCoachSnapshot(userId) + // The person's Coach lookback limit applies here too: the questions are + // asked from what the Coach may see. + const snapshotJson = await readCoachReach(userId) + .then((reach) => buildCoachSnapshot(userId, undefined, { reach })) .then((s) => s.snapshotJson || null) .catch(() => null); const { systemPrompt, userPrompt } = buildPrompts( diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index e95d4e3b3..3d21dae11 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -134,6 +134,8 @@ export function buildGlucoseBlock(ctx: Readonly): void { // rather than asserted as a clinical AGP. The headline mean is converted // ONCE to the user's display unit; the unit-agnostic fractions / indices // travel as-is. + // `null` rows: the panel's fixed window reaches past the Coach's lookback + // limit, so the panel is left out rather than computed over fewer days. const clinicalRaw = computeGlucoseClinicalMetrics( (glucoseClinicalRows ?? []).map((r) => ({ measuredAt: r.measuredAt, @@ -194,7 +196,11 @@ export function buildGlucoseBlock(ctx: Readonly): void { }; // The display unit travels with the block so the prompt renders // " " and the EVIDENCE BLOCK tags glucose lines correctly. - snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical }; + snapshot.glucose = { + unit: glucoseUnit, + byContext: contexts, + ...(glucoseClinicalRows !== null ? { clinical } : {}), + }; metrics.add("glucose"); counts.glucose = glucoseRows.length; registerBlock("glucose", "glucose"); diff --git a/src/lib/ai/coach/snapshot-cache.ts b/src/lib/ai/coach/snapshot-cache.ts index ccf534b99..4e3e77f11 100644 --- a/src/lib/ai/coach/snapshot-cache.ts +++ b/src/lib/ai/coach/snapshot-cache.ts @@ -22,6 +22,7 @@ */ import type { CoachSnapshotResult } from "./snapshot"; import type { CoachScope, CoachScopeWindow } from "./types"; +import { reachCacheToken, type CoachHistoryReach } from "./history-reach"; /** Default window when the caller doesn't pass a scope. */ export const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; @@ -36,6 +37,12 @@ const snapshotCache = new Map< export function snapshotCacheKey( userId: string, scope: CoachScope | undefined, + /** + * The lookback limit the build honoured. Part of the key so a build read + * without a limit (MCP) is never served to a limited Coach turn, nor the + * other way round. No limit adds nothing, so those keys are unchanged. + */ + reach?: CoachHistoryReach, ): string { // v1.7.0 — when the request pins an explicit source list, key on it. // Otherwise the source set is derived from the user's saved @@ -48,7 +55,7 @@ export function snapshotCacheKey( scope?.sources && scope.sources.length > 0 ? Array.from(scope.sources).sort().join(",") : "clusters"; - return `${userId}|${window}|${sourceList}`; + return `${userId}|${window}|${sourceList}${reachCacheToken(reach)}`; } export function readSnapshotCache(key: string): CoachSnapshotResult | null { diff --git a/src/lib/ai/coach/snapshot-prelude.ts b/src/lib/ai/coach/snapshot-prelude.ts index 1335a2a0f..5acdedc8f 100644 --- a/src/lib/ai/coach/snapshot-prelude.ts +++ b/src/lib/ai/coach/snapshot-prelude.ts @@ -19,6 +19,11 @@ import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; import type { CoachDataCluster } from "@/lib/validations/coach-prefs"; import { clusterSourcesFromPrefs, sourceCluster } from "./clusters"; import { resolveScope, windowToDays } from "./snapshot-series"; +import { + UNBOUNDED_REACH, + clampWindow, + type CoachHistoryReach, +} from "./history-reach"; import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types"; /** @@ -102,6 +107,12 @@ const CORE_CLUSTERS: ReadonlySet = new Set([ export async function resolveSnapshotPrelude( userId: string, scope: CoachScope | undefined, + /** + * The Coach's lookback limit, applied to the window here so every build + * that starts from the prelude reads under it. The single-source MCP build + * passes none and keeps its own window. + */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ) { const moduleMapPromise = resolveModuleMap(userId); // v1.20.0 (H-1) — the F1 coach tools each rebuild a single-source snapshot @@ -149,10 +160,11 @@ export async function resolveSnapshotPrelude( ? (prefsRow?.locale as Locale) : defaultLocale; const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters); - const { sources: scopedSources, window } = resolveScope( + const { sources: scopedSources, window: requestedWindow } = resolveScope( scope, clusterDefault, ); + const window = clampWindow(requestedWindow, reach); const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE; const units = resolveUnitPreferences({ unitPreference: prefsRow?.unitPreference, diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts index a62f14f88..70e3a7e24 100644 --- a/src/lib/ai/coach/snapshot-series.ts +++ b/src/lib/ai/coach/snapshot-series.ts @@ -18,6 +18,7 @@ import { buildTieredSeries } from "@/lib/rollups/tiered-context"; import type { MeasurementType } from "@/generated/prisma/client"; import { buildBaselineBand } from "@/lib/insights/derived/baseline"; import { DEFAULT_WINDOW } from "./snapshot-cache"; +import { COACH_DAILY_DETAIL_DAYS } from "./history-reach"; import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types"; const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; @@ -36,12 +37,12 @@ export function windowToDays(window: CoachScopeWindow): number { // stays bounded, but with explicit semantics — the Coach knows // the user is asking about a long-horizon view rather than the // unbounded "all time" backfill. - return 365; + return COACH_DAILY_DETAIL_DAYS; case "allTime": // Cap "allTime" at one year for the timeline. The aggregate // section already cites multi-year ranges via the features // pipeline; the timeline stays tight to keep token budget sane. - return 365; + return COACH_DAILY_DETAIL_DAYS; } } @@ -251,6 +252,13 @@ export async function buildCoarseTimelineTail( tz: string, /** The user's source-priority blob, already loaded by the caller. */ userPriorityJson: unknown, + /** + * The Coach's lookback floor, or null for none. Under a floor only the + * MONTH buckets that start inside it are kept; the YEAR band and the + * anomaly envelope go, because the envelope is scored against the spread + * of every coarse bucket, the older ones included. + */ + floor: Date | null = null, ): Promise { // `buildTieredSeries` reads fall back on miss — a coverage miss yields empty // bands, which collapse to `undefined` here. The try/catch keeps the coarse @@ -272,25 +280,39 @@ export async function buildCoarseTimelineTail( } catch { return undefined; } - const monthly = series.monthBand.map( - (b) => - [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], - ); - const yearly = series.yearBand.map( - (b) => - [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number], - ); - if ( - monthly.length === 0 && - yearly.length === 0 && - series.anomalies.length === 0 - ) { + const monthly = series.monthBand + .filter( + (b) => floor === null || Date.parse(b.bucketStart) >= floor.getTime(), + ) + .map( + (b) => + [b.bucketStart, b.mean, b.min, b.max] as [ + string, + number, + number, + number, + ], + ); + const yearly = + floor === null + ? series.yearBand.map( + (b) => + [b.bucketStart, b.mean, b.min, b.max] as [ + string, + number, + number, + number, + ], + ) + : []; + const anomalies = floor === null ? series.anomalies : []; + if (monthly.length === 0 && yearly.length === 0 && anomalies.length === 0) { return undefined; } return { monthly, yearly, - anomalies: series.anomalies.map((a) => ({ + anomalies: anomalies.map((a) => ({ band: a.band, date: a.date, kind: a.kind, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c89f9cd21..10ee28d0b 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -89,6 +89,16 @@ import { buildProfileContextBlocks, } from "./snapshot-blocks/context-blocks"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + UNBOUNDED_REACH, + fitsReach, + isBounded, + reachFloor, + type CoachHistoryReach, +} from "./history-reach"; +import { ADHERENCE_STORYLINE_HORIZON_DAYS } from "@/lib/insights/derived/adherence-storyline"; +import { CHANGEPOINT_WINDOW_DAYS } from "@/lib/insights/derived/changepoint"; +import { SIGNAL_TRUST_WINDOW_DAYS } from "@/lib/insights/derived/signal-trust"; // Test-only escape hatch — the suites import it from this module. export { __resetCoachSnapshotCacheForTests } from "./snapshot-cache"; @@ -140,6 +150,13 @@ export interface CoachSnapshotResult { */ const SLEEP_RHYTHM_WINDOW_DAYS = 42; +/** + * Where the coarse MONTH tail begins: the in-window weekly fold covers the + * first 90 days, the rollup tail what lies beyond. Under a lookback limit of + * 90 days or less the tail has nothing it may add. + */ +const COARSE_TAIL_START_DAYS = 90; + /** * Build the Coach prompt snapshot for `userId`. Always uses * `includeRaw=false` because the Coach replies are conversational and @@ -168,11 +185,20 @@ const SLEEP_RHYTHM_WINDOW_DAYS = 42; export async function buildCoachSnapshot( userId: string, scope?: CoachScope, + options?: { + /** + * How far back the build may read (`history-reach.ts`). Every Coach + * caller passes the person's limit; MCP passes none and reads without + * one, as it always did. + */ + reach?: CoachHistoryReach; + }, ): Promise { - const key = snapshotCacheKey(userId, scope); + const reach = options?.reach ?? UNBOUNDED_REACH; + const key = snapshotCacheKey(userId, scope, reach); const cached = readSnapshotCache(key); if (cached) return cached; - const result = await buildCoachSnapshotImpl(userId, scope); + const result = await buildCoachSnapshotImpl(userId, scope, reach); writeSnapshotCache(key, result); return result; } @@ -202,9 +228,12 @@ function blockFailed(block: string): (err: unknown) => null { async function buildCoachSnapshotImpl( userId: string, - scope?: CoachScope, + scope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { // Prefs, module gates and the admitted sources — see `snapshot-prelude.ts`. + // The lookback limit caps the narration window there, whatever the caller + // asked for; every fixed-window block below answers to the same limit. const { prefsRow, coachLocale, @@ -214,7 +243,8 @@ async function buildCoachSnapshotImpl( recoveryDisabled, excluded, sources, - } = await resolveSnapshotPrelude(userId, scope); + } = await resolveSnapshotPrelude(userId, scope, reach); + const bounded = isBounded(reach); const glucoseUnit = units.glucoseUnit; // v1.4.36 W3 T2 — `medications` and `anthropometrics` are // exclude-only toggles (not in `CoachScopeSource`); they gate the @@ -247,9 +277,16 @@ async function buildCoachSnapshotImpl( // do not share this read; memoise it per-request keyed on the only varying // input (windowDays) so the fan-out runs it once instead of up to 6× against // the shared pool. + // Under a lookback limit the aggregate's "all time" figures, the mood + // history and its sub-blocks are read from inside the limit only. + const featuresFloor = reachFloor(reach); const featuresPromise = memoizePerRequest( - `coach-features:${userId}:${windowDays}`, - () => extractFeatures(userId, false, { sinceDays: windowDays }), + `coach-features:${userId}:${windowDays}:${reach.days ?? "all"}`, + () => + extractFeatures(userId, false, { + sinceDays: windowDays, + ...(featuresFloor ? { historyFloor: featuresFloor } : {}), + }), ); // Trim down to the metrics the Coach narrates. extractFeatures @@ -321,31 +358,39 @@ async function buildCoachSnapshotImpl( const coarseTailPromises: Partial< Record<"bp" | "weight" | "pulse", Promise> > = {}; - if (wantsBp) { + // The coarse tail starts where the in-window weekly fold ends (90 days), so + // a limit of 90 days or less leaves nothing for it to add. + const coarseTailAllowed = + reach.days === null || reach.days > COARSE_TAIL_START_DAYS; + const coarseFloor = reachFloor(reach, now); + if (wantsBp && coarseTailAllowed) { coarseTailPromises.bp = buildCoarseTimelineTail( userId, "BLOOD_PRESSURE_SYS" as MeasurementType, now, userTz, prefsRow?.sourcePriorityJson ?? null, + coarseFloor, ); } - if (wantsWeight) { + if (wantsWeight && coarseTailAllowed) { coarseTailPromises.weight = buildCoarseTimelineTail( userId, "WEIGHT" as MeasurementType, now, userTz, prefsRow?.sourcePriorityJson ?? null, + coarseFloor, ); } - if (wantsPulse) { + if (wantsPulse && coarseTailAllowed) { coarseTailPromises.pulse = buildCoarseTimelineTail( userId, "PULSE" as MeasurementType, now, userTz, prefsRow?.sourcePriorityJson ?? null, + coarseFloor, ); } const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([ @@ -480,10 +525,13 @@ async function buildCoachSnapshotImpl( now.getTime() - SLEEP_RHYTHM_WINDOW_DAYS * 24 * 60 * 60 * 1000, ); // source + deviceType feed the canonical writer-dedup so a multi-source - // night is counted ONCE, matching every other sleep surface. - const sleepRhythmRowsPromise = sources.has("sleep") - ? readSleepStageRows(userId, sleepRhythmCutoff) - : null; + // night is counted ONCE, matching every other sleep surface. A limit + // shorter than the rhythm's fixed 42 days leaves the block out: a debt or + // chronotype band over fewer nights is not the one the page shows. + const sleepRhythmRowsPromise = + sources.has("sleep") && fitsReach(SLEEP_RHYTHM_WINDOW_DAYS, reach) + ? readSleepStageRows(userId, sleepRhythmCutoff) + : null; const workoutRowsPromise = sources.has("workouts") ? prisma.workout.findMany({ where: { userId, startedAt: { gte: additiveCutoff("workouts") } }, @@ -514,23 +562,26 @@ async function buildCoachSnapshotImpl( const glucoseClinicalCutoff = new Date( now.getTime() - GLUCOSE_CLINICAL_WINDOW_DAYS * 24 * 60 * 60 * 1000, ); - const glucoseClinicalRowsPromise = sources.has("glucose") - ? prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_GLUCOSE" as never, - measuredAt: { gte: glucoseClinicalCutoff }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true }, - }) - : null; + // Same rule for the fixed 30-day clinical panel: under a shorter limit the + // panel is left out (null rows), never computed over fewer days. + const glucoseClinicalRowsPromise = + sources.has("glucose") && fitsReach(GLUCOSE_CLINICAL_WINDOW_DAYS, reach) + ? prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE" as never, + measuredAt: { gte: glucoseClinicalCutoff }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }) + : null; const glp1BlockPromise = excludesMedications ? null - : buildGlp1SnapshotBlock(userId, now, userTz); + : buildGlp1SnapshotBlock(userId, now, userTz, reach); const derivedBlockPromise = derivedActive - ? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz) + ? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz, reach) : null; // v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED // STRAIN_SCORE (0–100) the derived block carries. Gated on the same @@ -550,7 +601,7 @@ async function buildCoachSnapshotImpl( }) : null; const trajectoryBlockPromise = derivedActive - ? buildTrajectorySnapshotBlock(userId, derivedProfile, now) + ? buildTrajectorySnapshotBlock(userId, derivedProfile, now, reach) : null; // RECON1 (D5-5) — discovered cross-metric driver pairs for the no-tools // snapshot floor. Reuses the SAME gated/ranked output the get_correlations @@ -560,13 +611,14 @@ async function buildCoachSnapshotImpl( // instead of getting only the coincident flag. Gated on `derivedActive` (it // is the recovery / cross-metric layer) and fail-soft to null. const correlationsBlockPromise = derivedActive - ? buildCorrelationsSnapshotBlock(userId, coachLocale) + ? buildCorrelationsSnapshotBlock(userId, coachLocale, reach) : null; const memoryBlockPromise = buildCoachMemoryBlock( userId, derivedProfile, now, coachLocale, + reach, ); // v1.15 — cycle/phase block, gated on the resolved cycle module so a // non-cycle account issues no query (the helper short-circuits to null @@ -578,16 +630,20 @@ async function buildCoachSnapshotImpl( // server-wide kill-switch), so an operator-off instance never injects the // cycle block into the coach prompt. const cycleEnabled = await isCycleAvailableForUser(userId); - const cycleBlockPromise = cycleEnabled - ? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz) - : null; + // The cycle block predicts from every logged cycle, of any age, so that its + // "day N, period in M days" matches the calendar. Any lookback limit leaves + // it out rather than predicting from a part of the history. + const cycleBlockPromise = + cycleEnabled && !bounded + ? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz) + : null; // v1.18.1 P4 — illness/condition context. Always attempted (the helper is // module-gated internally and short-circuits to null for a non-illness // account). It is CONTEXT, not a scope-gated metric: appended like // anthropometrics/scope below without a `registerBlock` so the budget // degrader never sheds it — the Coach needs to know about Rest Mode. - const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now); + const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now, reach); // v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a // scope-gated metric: attached without a `registerBlock` so the budget @@ -595,7 +651,7 @@ async function buildCoachSnapshotImpl( // helper reads owner-scoped rows directly, mirroring `/api/labs`). The block // carries the most-recent resolved reading per biomarker (last 12 months, // capped) so the Coach can answer "what was my LDL" without re-deriving. - const labsBlockPromise = buildLabsSnapshotBlock(userId, now); + const labsBlockPromise = buildLabsSnapshotBlock(userId, now, reach); // v1.38 — doctor-visit context. Like illness/labs it is attempted always (a // visit is core, never module-gated) and short-circuits to null when there is @@ -603,7 +659,7 @@ async function buildCoachSnapshotImpl( // UNLIKE illness/labs it IS registered for degradation below (against the // lowest-priority cluster), because a visit history is low-frequency context // the Coach can lose under budget pressure without losing a safety flag. - const visitsBlockPromise = buildVisitsSnapshotBlock(userId, now); + const visitsBlockPromise = buildVisitsSnapshotBlock(userId, now, reach); const [ moodRows, @@ -900,22 +956,31 @@ async function buildCoachSnapshotImpl( // bounded to its domain and avoids spurious recovery/vital reads. const isExplicitlyScoped = Array.isArray(scope?.sources) && scope.sources.length > 0; + // Each of these reads its own fixed window; one that reaches past the + // lookback limit is left out. The experiment read-back compares against a + // baseline from before the plan began, of no fixed length, so any limit + // leaves it out. const [adherenceStoryline, changepoints, signalTrust, experimentOutcomes] = isExplicitlyScoped ? [null, null, null, null] : await Promise.all([ - excludesMedications + excludesMedications || + !fitsReach(ADHERENCE_STORYLINE_HORIZON_DAYS, reach) ? Promise.resolve(null) : buildAdherenceStoryline(userId, userTz, now).catch( blockFailed("adherenceStoryline"), ), - buildChangepointSignals(userId, now).catch( - blockFailed("changepoints"), - ), - buildSignalTrust(userId, userTz, now).catch( - blockFailed("signalTrust"), - ), - experimentVerdictEnabled() + fitsReach(CHANGEPOINT_WINDOW_DAYS, reach) + ? buildChangepointSignals(userId, now).catch( + blockFailed("changepoints"), + ) + : Promise.resolve(null), + fitsReach(SIGNAL_TRUST_WINDOW_DAYS, reach) + ? buildSignalTrust(userId, userTz, now).catch( + blockFailed("signalTrust"), + ) + : Promise.resolve(null), + experimentVerdictEnabled() && !bounded ? buildExperimentOutcomeBlock(userId, { now }).catch( blockFailed("experimentOutcomes"), ) @@ -941,6 +1006,8 @@ async function buildCoachSnapshotImpl( window, sources: Array.from(sources), timelineRecentDays: DAILY_TIMELINE_DAYS, + // Named only under a limit, so an unlimited snapshot stays as it was. + ...(bounded ? { lookbackLimit: reach.window } : {}), }; // v1.4.36 W3 T4 — compactSections drops any zero-row block before diff --git a/src/lib/ai/coach/tools/availability.ts b/src/lib/ai/coach/tools/availability.ts index c7c35110c..17e03cfa2 100644 --- a/src/lib/ai/coach/tools/availability.ts +++ b/src/lib/ai/coach/tools/availability.ts @@ -29,6 +29,15 @@ * nothing and we could not establish whether older rows * exist, so the model is told exactly that rather than * being handed a confident absence it cannot verify. + * `outside_reach` — rows exist, every one of them older than the lookback + * limit the person set for the Coach. Nothing about them + * is reported, not a count, a date or a mean: the limit + * says the Coach does not read them, and an aggregate is + * a reading. The model says older readings lie beyond the + * limit and can be opened in the Coach settings. + * + * Under a limit every probe below reads from inside it only, and + * `reachableWithWindow` never names a window wider than the limit. * * Cost. One grouped aggregate over `Measurement` for every measurement-backed * subject in the batch (indexed by `(userId, type, measuredAt)`, no rows @@ -55,6 +64,12 @@ import type { CoachToolName } from "./definitions"; import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; import { windowToDays } from "@/lib/ai/coach/snapshot-series"; import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache"; +import { + UNBOUNDED_REACH, + exceedsReach, + reachFloor, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; /** Which table a domain's existence has to be checked against. */ export type CoachAvailabilitySubject = @@ -119,9 +134,14 @@ const WINDOWS_WIDEST_LAST: readonly CoachScopeWindow[] = [ * `lastYear` (see `windowToDays`), so a history older than that is genuinely * unreachable by re-calling with a wider window — and saying so is the point. */ -function reachableWindow(lastAt: Date, now: Date): CoachScopeWindow | null { +function reachableWindow( + lastAt: Date, + now: Date, + reach: CoachHistoryReach, +): CoachScopeWindow | null { const ageDays = (now.getTime() - lastAt.getTime()) / 86_400_000; for (const window of WINDOWS_WIDEST_LAST) { + if (exceedsReach(window, reach)) break; if (ageDays <= windowToDays(window)) return window; } return null; @@ -148,16 +168,22 @@ interface RawBounds { async function readMeasurementBounds( userId: string, types: readonly MeasurementType[], + floor: Date | null, ): Promise> { const rows = await prisma.measurement.groupBy({ by: ["type", "unit"], - where: { userId, deletedAt: null, type: { in: [...types] } }, + where: { + userId, + deletedAt: null, + type: { in: [...types] }, + ...(floor ? { measuredAt: { gte: floor } } : {}), + }, _count: { _all: true }, _min: { measuredAt: true, value: true }, _max: { measuredAt: true, value: true }, _avg: { value: true }, }); - const dayMeans = await readHourlyMeanTypeMeans(userId, types); + const dayMeans = await readHourlyMeanTypeMeans(userId, types, floor); const out = new Map(); for (const row of rows) { const firstAt = row._min.measuredAt; @@ -212,6 +238,7 @@ async function readMeasurementBounds( async function readHourlyMeanTypeMeans( userId: string, types: readonly MeasurementType[], + floor: Date | null, ): Promise> { const hourly = types.filter((t) => usesHourlyMeanDay(t)); const out = new Map(); @@ -228,6 +255,7 @@ async function readHourlyMeanTypeMeans( AND m."type" IN (${Prisma.join( hourly.map((t) => Prisma.sql`${t}::measurement_type`), )}) + ${floor ? Prisma.sql`AND m."measured_at" >= ${floor}` : Prisma.empty} ) SELECT m."type"::text AS type, @@ -252,10 +280,15 @@ async function readHourlyMeanTypeMeans( async function readTableBounds( userId: string, kind: Exclude, + floor: Date | null, ): Promise { if (kind === "mood") { const row = await prisma.moodEntry.aggregate({ - where: { userId, deletedAt: null }, + where: { + userId, + deletedAt: null, + ...(floor ? { moodLoggedAt: { gte: floor } } : {}), + }, _count: { _all: true }, _min: { moodLoggedAt: true }, _max: { moodLoggedAt: true }, @@ -271,7 +304,7 @@ async function readTableBounds( if (kind === "workout") { // Workout deletes are hard deletes — there is no `deletedAt` to filter. const row = await prisma.workout.aggregate({ - where: { userId }, + where: { userId, ...(floor ? { startedAt: { gte: floor } } : {}) }, _count: { _all: true }, _min: { startedAt: true }, _max: { startedAt: true }, @@ -286,7 +319,11 @@ async function readTableBounds( } if (kind === "intake") { const row = await prisma.medicationIntakeEvent.aggregate({ - where: { userId, deletedAt: null }, + where: { + userId, + deletedAt: null, + ...(floor ? { scheduledFor: { gte: floor } } : {}), + }, _count: { _all: true }, _min: { scheduledFor: true }, _max: { scheduledFor: true }, @@ -300,7 +337,11 @@ async function readTableBounds( : null; } const row = await prisma.labResult.aggregate({ - where: { userId, deletedAt: null }, + where: { + userId, + deletedAt: null, + ...(floor ? { takenAt: { gte: floor } } : {}), + }, _count: { _all: true }, _min: { takenAt: true }, _max: { takenAt: true }, @@ -345,11 +386,13 @@ function mergeBounds(parts: readonly RawBounds[]): RawBounds | null { export async function probeCoachAvailability( userId: string, subjects: ReadonlyMap, - options?: { now?: Date }, + options?: { now?: Date; reach?: CoachHistoryReach }, ): Promise> { const out = new Map(); if (subjects.size === 0) return out; const now = options?.now ?? new Date(); + const reach = options?.reach ?? UNBOUNDED_REACH; + const floor = reachFloor(reach, now); const measurementTypes = new Set(); const tableKinds = new Set< @@ -366,11 +409,11 @@ export async function probeCoachAvailability( const [tz, measurementBounds, ...tableRows] = await Promise.all([ resolveUserTimezone(userId), measurementTypes.size > 0 - ? readMeasurementBounds(userId, [...measurementTypes]) + ? readMeasurementBounds(userId, [...measurementTypes], floor) : Promise.resolve(new Map()), ...[...tableKinds].map(async (kind) => ({ kind, - bounds: await readTableBounds(userId, kind), + bounds: await readTableBounds(userId, kind, floor), })), ]); const byKind = new Map(tableRows.map((r) => [r.kind, r.bounds])); @@ -390,12 +433,117 @@ export async function probeCoachAvailability( firstDate: userDayKey(raw.firstAt, tz), lastDate: userDayKey(raw.lastAt, tz), ...(raw.series && raw.series.length > 0 ? { series: raw.series } : {}), - reachableWithWindow: reachableWindow(raw.lastAt, now), + reachableWithWindow: reachableWindow(raw.lastAt, now, reach), }); } return out; } +/** + * The subjects among `subjects` that hold rows older than the lookback limit. + * Existence only, one `findFirst` per subject: what lies beyond the limit is + * never counted or aggregated. Empty without a limit. + */ +export async function probeBeyondReach( + userId: string, + subjects: ReadonlyMap, + reach: CoachHistoryReach, + now: Date = new Date(), +): Promise> { + const floor = reachFloor(reach, now); + const out = new Set(); + if (floor === null) return out; + await Promise.all( + [...subjects].map(async ([key, subject]) => { + if (await holdsRowsBefore(userId, subject, floor)) out.add(key); + }), + ); + return out; +} + +async function holdsRowsBefore( + userId: string, + subject: CoachAvailabilitySubject, + floor: Date, +): Promise { + switch (subject.kind) { + case "measurement": + return ( + (await prisma.measurement.findFirst({ + where: { + userId, + deletedAt: null, + type: { in: [...subject.types] }, + measuredAt: { lt: floor }, + }, + select: { id: true }, + })) !== null + ); + case "mood": + return ( + (await prisma.moodEntry.findFirst({ + where: { userId, deletedAt: null, moodLoggedAt: { lt: floor } }, + select: { id: true }, + })) !== null + ); + case "workout": + return ( + (await prisma.workout.findFirst({ + where: { userId, startedAt: { lt: floor } }, + select: { id: true }, + })) !== null + ); + case "intake": + return ( + (await prisma.medicationIntakeEvent.findFirst({ + where: { userId, deletedAt: null, scheduledFor: { lt: floor } }, + select: { id: true }, + })) !== null + ); + case "lab": + return ( + (await prisma.labResult.findFirst({ + where: { userId, deletedAt: null, takenAt: { lt: floor } }, + select: { id: true }, + })) !== null + ); + } +} + +/** + * True when the record holds illness episodes that began before the lookback + * floor. The illness domain has no row subject (its block is computed), so a + * limited read that comes back empty asks this to tell "never recorded" from + * "recorded, beyond the limit". Always false without a limit. + */ +export async function illnessBeyondReach( + userId: string, + reach: CoachHistoryReach, + now: Date = new Date(), +): Promise { + const floor = reachFloor(reach, now); + if (floor === null) return false; + const row = await prisma.illnessEpisode.findFirst({ + where: { userId, deletedAt: null, onsetAt: { lt: floor } }, + select: { id: true }, + }); + return row !== null; +} + +/** + * True when the record holds any logged cycle. Under a lookback limit the + * cycle block is left out whole (it predicts from every cycle), so an + * account with cycles is told they lie beyond the limit, and one without is + * told there are none. + */ +export async function cycleRecorded(userId: string): Promise { + const row = await prisma.menstrualCycle.findFirst({ + where: { userId, deletedAt: null }, + select: { id: true }, + }); + return row !== null; +} + /** * The reason codes a `present: false` tool result may carry when it came from * an empty window read. Frozen so a future reader (a test, a dashboard) can @@ -410,8 +558,13 @@ const EMPTY_READ_REASONS = { unavailableInScope: "unavailable_in_scope", /** The probe failed; absence could not be established either way. */ unconfirmed: "no_data_unconfirmed", + /** Rows exist, all of them older than the person's lookback limit. */ + outsideReach: "outside_reach", } as const; +/** The reason a read gives for data that lies only beyond the lookback limit. */ +export const OUTSIDE_REACH_REASON = EMPTY_READ_REASONS.outsideReach; + /** * Split a "rows exist" verdict into the two cases that differ in what the model * should do next. @@ -456,8 +609,11 @@ export async function resolveEmptyRead(args: { subject: CoachAvailabilitySubject | null; searchedWindow: CoachScopeWindow | undefined; now?: Date; + /** The person's lookback limit; absent (MCP) is none. */ + reach?: CoachHistoryReach; }): Promise { const { userId, domain, subject, searchedWindow } = args; + const reach = args.reach ?? UNBOUNDED_REACH; const searched = searchedWindow ? { searchedWindow } : {}; // A domain with no row-backed subject (illness, cycle, correlations) is not // window-filtered from a table of rows — its block is computed or gated, so @@ -470,8 +626,27 @@ export async function resolveEmptyRead(args: { availability = await probeCoachAvailability( userId, new Map([[domain, subject]]), - args.now ? { now: args.now } : undefined, + { ...(args.now ? { now: args.now } : {}), reach }, ); + if (!availability.has(domain)) { + const beyond = await probeBeyondReach( + userId, + new Map([[domain, subject]]), + reach, + args.now, + ); + if (beyond.has(domain)) { + annotate({ + action: { name: "coach.availability.resolved" }, + meta: { domain, reason: EMPTY_READ_REASONS.outsideReach }, + }); + return { + present: false, + reason: EMPTY_READ_REASONS.outsideReach, + ...searched, + }; + } + } } catch (err) { annotate({ action: { name: "coach.availability.probe_failed" }, diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 25961bdc8..7c49af712 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -34,6 +34,12 @@ * interpretation string — so the Coach states the observed linkage without * inventing a relationship. */ +import { + UNBOUNDED_REACH, + capDays, + isBounded, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; import { prisma } from "@/lib/db"; import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import type { Locale } from "@/lib/i18n/config"; @@ -62,6 +68,9 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; /** Trailing window for the discovery scan — mirrors the insight route. */ const WINDOW_DAYS = 180; +/** The coincident-deviation flag's own default window. */ +const COINCIDENT_WINDOW_DAYS = 30; + /** One discovered driver pair, descriptive — never causal. */ export interface CoachCorrelationDriver { behaviour: string; @@ -180,7 +189,18 @@ export async function readCoachCorrelations( * German page in English. */ locale: Locale, + options?: { + /** + * The Coach's lookback limit: the scan, the early-detection pass and the + * coincident flag read no further back than it. Absent (MCP, the metric + * page strip): the fixed 180-day scan as before. + */ + reach?: CoachHistoryReach; + }, ): Promise { + const reach = options?.reach ?? UNBOUNDED_REACH; + const windowDays = capDays(WINDOW_DAYS, reach); + const earlyDays = capDays(EARLY_WINDOW_DAYS, reach); // v1.30.22 — the `insights` gate lives HERE, at the read, not at each // caller. This reader is reached from four places (the Coach // `get_correlations` tool, the MCP `get_correlation` rich read, the @@ -211,7 +231,7 @@ export async function readCoachCorrelations( select: { timezone: true }, }); const tz = userRow?.timezone ?? DEFAULT_TIMEZONE; - const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); + const since = new Date(Date.now() - windowDays * MS_PER_DAY); // The channel set is the shared one, so the Coach scans exactly what the // correlations page scans. The measurement read stays `"raw"` (the route's @@ -228,7 +248,12 @@ export async function readCoachCorrelations( // baseline hiccup never sinks the whole correlations read. D2-8: pass the // user's tz so the "today" grouping matches the user's calendar day, not // UTC's, before the fired flag is narrated as "out of band TODAY". - computeCoincidentDeviation(userId, profile, { tz }).catch(() => null), + computeCoincidentDeviation(userId, profile, { + tz, + ...(isBounded(reach) + ? { windowDays: capDays(COINCIDENT_WINDOW_DAYS, reach) } + : {}), + }).catch(() => null), isSurfaceVisible("correlation:LAB_DRAWS", modules) ? fetchLabDraws(userId, tz, since) : Promise.resolve([]), @@ -268,7 +293,7 @@ export async function readCoachCorrelations( // scan already established, so the Coach never narrates the same pattern as // both "established" and "emerging". const recentFromDayKey = tzDayKey( - new Date(Date.now() - EARLY_WINDOW_DAYS * MS_PER_DAY), + new Date(Date.now() - earlyDays * MS_PER_DAY), tz, ); const emergingResult = discoverEmergingCorrelations(series, discovery, { @@ -323,7 +348,7 @@ export async function readCoachCorrelations( ...(labDrivers.length > 0 ? { labDrivers } : {}), ...(coincident ? { coincident } : {}), pairsTested: discovery.pairsTested, - windowDays: WINDOW_DAYS, + windowDays, }; } catch { return { present: false, reason: "retrieval_failed" }; diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index 20335214e..69017aa35 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -202,6 +202,15 @@ const WINDOW_ENUM = [ * terse and brand-free; they tell the model WHEN to reach for each tool and * that an absent domain returns `{ present: false }` rather than an error. */ +/** + * Every `window` argument says the same thing: it defaults to the + * conversation's window and is cut to the lookback limit the person set. The + * definitions stay the same for every person (the prompt-cache prefix), so + * the limit itself rides the DATA INVENTORY, not this text. + */ +const WINDOW_ARG_DESCRIPTION = + "Analysis window. Defaults to the user's scope window. Never reaches further back than the lookback limit the user set: a wider window is cut to it."; + export const COACH_TOOL_DEFS: AiToolDef[] = [ { name: "get_metric_series", @@ -220,7 +229,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ window: { type: "string", enum: WINDOW_ENUM, - description: "Analysis window. Defaults to the user's scope window.", + description: WINDOW_ARG_DESCRIPTION, }, }, }, @@ -238,7 +247,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ type: "string", enum: WINDOW_ENUM, description: - "Window for the per-context means. The clinical panel is always the fixed trailing 30 days.", + "Window for the per-context means, cut to the user's lookback limit like every window. The clinical panel is always the fixed trailing 30 days, and is left out when the lookback limit is shorter.", }, }, }, @@ -255,7 +264,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ window: { type: "string", enum: WINDOW_ENUM, - description: "Analysis window. Defaults to the user's scope window.", + description: WINDOW_ARG_DESCRIPTION, }, }, }, @@ -272,7 +281,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ window: { type: "string", enum: WINDOW_ENUM, - description: "Analysis window. Defaults to the user's scope window.", + description: WINDOW_ARG_DESCRIPTION, }, }, }, @@ -280,7 +289,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ { name: "get_labs", description: - "Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, or one named analyte. Returns { present: false } with a reason — no_data means no labs are on file, outside_window means panels exist but are older than the twelve months this tool reads.", + "Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months (or the user's lookback limit, when shorter), or one named analyte. Returns { present: false } with a reason — no_data means no labs are on file, outside_window means panels exist but are older than the twelve months this tool reads, outside_reach means they are older than the user's lookback limit.", parameters: { type: "object", additionalProperties: false, @@ -317,7 +326,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ window: { type: "string", enum: WINDOW_ENUM, - description: "Analysis window. Defaults to the user's scope window.", + description: WINDOW_ARG_DESCRIPTION, }, }, }, @@ -361,7 +370,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ window: { type: "string", enum: WINDOW_ENUM, - description: "Analysis window. Defaults to the user's scope window.", + description: WINDOW_ARG_DESCRIPTION, }, granularity: { type: "string", diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index c7b52b897..b1f4f94e3 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -52,6 +52,7 @@ import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; import type { CoachPrefs } from "@/lib/validations/coach-prefs"; import { readMessageResults } from "@/lib/ai/coach/persistence"; import type { + CoachResultPeriod, CoachResultTable, CoachScope, CoachScopeSource, @@ -63,6 +64,7 @@ import { METRIC_TABLE_EXCLUDED_SOURCES, compareWithCurrent, readMetricTable, + resolveTableRange, summariseTable, } from "@/lib/ai/coach/results/metric-table-tool"; import { @@ -119,6 +121,20 @@ import { type CoachDomainAvailability, } from "./availability"; import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import { + UNBOUNDED_REACH, + clampWindow, + exceedsReach, + isBounded, + withinReach, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; +import { + OUTSIDE_REACH_REASON, + cycleRecorded, + illnessBeyondReach, +} from "./availability"; /** A read-only structured tool result. Serialised to a `role:"tool"` turn. */ export interface CoachToolResult { @@ -192,6 +208,19 @@ function pickSection( return value; } +/** + * A tool's window under the lookback limit: whatever the call asks for, it + * reads no further back than the limit. `undefined` stays undefined (the + * builder default, 30 days, is inside every limit but the 7-day one, and the + * builder clamps that itself). + */ +function limitWindow( + window: CoachScopeWindow | undefined, + reach: CoachHistoryReach, +): CoachScopeWindow | undefined { + return window === undefined ? undefined : clampWindow(window, reach); +} + /** * Resolve the scope a tool reads its snapshot under. * @@ -210,8 +239,9 @@ function scopeFor( window: CoachScopeWindow | undefined, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): CoachScope { - const effectiveWindow = window ?? fallbackWindow; + const effectiveWindow = limitWindow(window ?? fallbackWindow, reach); if (sharedScope && sharedScope.window === effectiveWindow) { // Same window as the inventory's full-source build → reuse its cache entry. return sharedScope; @@ -251,6 +281,13 @@ export async function executeCoachTool(args: { sourceSnapshot?: boolean; /** v1.39.4 — the chat turn, when the call runs inside one. */ turn?: CoachToolTurnContext; + /** + * How far back the call may read: the person's Coach lookback limit. Every + * read below is clamped to it. Absent is no limit, which is what an MCP + * client gets: it reads with its own window arguments, and the Coach + * setting governs the Coach. + */ + reach?: CoachHistoryReach; }): Promise { const { userId, @@ -261,6 +298,7 @@ export async function executeCoachTool(args: { sourceSnapshot = false, turn, } = args; + const reach = args.reach ?? UNBOUNDED_REACH; if (!isCoachToolName(name) && name !== SHOW_RESULT_TOOL_NAME) { annotate({ @@ -284,7 +322,7 @@ export async function executeCoachTool(args: { try { const result = name === SHOW_RESULT_TOOL_NAME - ? await showResult(userId, parsedArgs, sharedScope, turn) + ? await showResult(userId, parsedArgs, sharedScope, turn, reach) : await dispatch( name as CoachToolName, userId, @@ -292,6 +330,7 @@ export async function executeCoachTool(args: { fallbackWindow, sharedScope, turn, + reach, sourceSnapshot, ); annotate({ @@ -320,6 +359,7 @@ async function dispatch( fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, turn: CoachToolTurnContext | undefined, + reach: CoachHistoryReach, sourceSnapshot: boolean, ): Promise { const result = await dispatchRead( @@ -329,6 +369,7 @@ async function dispatch( fallbackWindow, sharedScope, turn, + reach, sourceSnapshot, ); if (!turn || !result.present || result.table) return result; @@ -339,6 +380,7 @@ async function dispatch( fallbackWindow, result, turn, + reach, ); } @@ -349,8 +391,11 @@ async function dispatchRead( fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, turn: CoachToolTurnContext | undefined, + reach: CoachHistoryReach, sourceSnapshot: boolean, ): Promise { + // Every branch takes the lookback limit; `coach-history-reach-guard.test.ts` + // fails when one does not. switch (name) { case "get_metric_series": return getMetricSeries( @@ -358,31 +403,46 @@ async function dispatchRead( rawArgs, fallbackWindow, sharedScope, + reach, sourceSnapshot, ); case "get_glucose_panel": - return getGlucosePanel(userId, rawArgs, fallbackWindow, sharedScope); + return getGlucosePanel( + userId, + rawArgs, + fallbackWindow, + sharedScope, + reach, + ); case "get_sleep": - return getSleep(userId, rawArgs, fallbackWindow, sharedScope); + return getSleep(userId, rawArgs, fallbackWindow, sharedScope, reach); case "get_medication_compliance": return getMedicationCompliance( userId, rawArgs, fallbackWindow, sharedScope, + reach, ); case "get_labs": - return getLabs(userId, rawArgs, fallbackWindow, sharedScope); + return getLabs(userId, rawArgs, fallbackWindow, sharedScope, reach); case "get_illness_recovery": - return getIllnessRecovery(userId, fallbackWindow, sharedScope); + return getIllnessRecovery(userId, fallbackWindow, sharedScope, reach); case "get_workouts": - return getWorkouts(userId, rawArgs, fallbackWindow, sharedScope); + return getWorkouts(userId, rawArgs, fallbackWindow, sharedScope, reach); case "get_cycle": - return getCycle(userId, rawArgs, sharedScope); + return getCycle(userId, rawArgs, sharedScope, reach); case "get_correlations": - return getCorrelations(userId, rawArgs); + return getCorrelations(userId, rawArgs, reach); case "get_metric_table": - return getMetricTable(userId, rawArgs, fallbackWindow, sharedScope, turn); + return getMetricTable( + userId, + rawArgs, + fallbackWindow, + sharedScope, + turn, + reach, + ); } } @@ -404,8 +464,9 @@ function emptyRead( domain: string, subject: CoachAvailabilitySubject | null, searchedWindow: CoachScopeWindow | undefined, + reach: CoachHistoryReach, ): Promise { - return resolveEmptyRead({ userId, domain, subject, searchedWindow }); + return resolveEmptyRead({ userId, domain, subject, searchedWindow, reach }); } async function getMetricSeries( @@ -413,6 +474,7 @@ async function getMetricSeries( rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, sourceSnapshot: boolean, ): Promise { const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs); @@ -441,12 +503,14 @@ async function getMetricSeries( // An MCP read needs one section and nothing else, so it is built from that // source's own rows: the same section, without the context blocks a Coach - // turn carries. See `source-snapshot.ts`. + // turn carries. See `source-snapshot.ts`. Only MCP asks for it, and MCP + // reads without the Coach's lookback limit, so it takes no reach. const snapshot = sourceSnapshot ? await buildCoachSourceSnapshot(userId, metric, window ?? fallbackWindow) : await buildCoachSnapshot( userId, - scopeFor([metric], window, fallbackWindow, sharedScope), + scopeFor([metric], window, fallbackWindow, sharedScope, reach), + { reach }, ); const section = pickSection(snapshot.sections, sectionKey); if (section === undefined) { @@ -454,7 +518,8 @@ async function getMetricSeries( userId, metric, subjectForTool("get_metric_series", metric), - window ?? fallbackWindow, + limitWindow(window ?? fallbackWindow, reach), + reach, ); } return { @@ -469,12 +534,20 @@ async function getGlucosePanel( rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_glucose_panel", parsed.error); const snapshot = await buildCoachSnapshot( userId, - scopeFor(["glucose"], parsed.data.window, fallbackWindow, sharedScope), + scopeFor( + ["glucose"], + parsed.data.window, + fallbackWindow, + sharedScope, + reach, + ), + { reach }, ); const section = pickSection(snapshot.sections, "glucose"); if (section === undefined) { @@ -482,7 +555,8 @@ async function getGlucosePanel( userId, "glucose", subjectForTool("get_glucose_panel"), - parsed.data.window ?? fallbackWindow, + limitWindow(parsed.data.window ?? fallbackWindow, reach), + reach, ); } return { @@ -497,12 +571,14 @@ async function getSleep( rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = getSleepArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_sleep", parsed.error); const snapshot = await buildCoachSnapshot( userId, - scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope), + scopeFor(["sleep"], parsed.data.window, fallbackWindow, sharedScope, reach), + { reach }, ); const nights = pickSection(snapshot.sections, "sleep"); const rhythm = pickSection(snapshot.sections, "sleepRhythm"); @@ -511,7 +587,8 @@ async function getSleep( userId, "sleep", subjectForTool("get_sleep"), - parsed.data.window ?? fallbackWindow, + limitWindow(parsed.data.window ?? fallbackWindow, reach), + reach, ); } return { @@ -529,6 +606,7 @@ async function getMedicationCompliance( rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs); if (!parsed.success) { @@ -536,7 +614,14 @@ async function getMedicationCompliance( } const snapshot = await buildCoachSnapshot( userId, - scopeFor(["compliance"], parsed.data.window, fallbackWindow, sharedScope), + scopeFor( + ["compliance"], + parsed.data.window, + fallbackWindow, + sharedScope, + reach, + ), + { reach }, ); const compliance = pickSection(snapshot.sections, "compliance"); // GLP-1 context rides the `weeklyContext` block. @@ -548,7 +633,8 @@ async function getMedicationCompliance( userId, "compliance", subjectForTool("get_medication_compliance"), - parsed.data.window ?? fallbackWindow, + limitWindow(parsed.data.window ?? fallbackWindow, reach), + reach, ); } return { @@ -565,6 +651,7 @@ async function getLabs( rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = getLabsArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_labs", parsed.error); @@ -582,7 +669,8 @@ async function getLabs( // (labs ride unconditionally) when no shared scope or a window mismatch. const snapshot = await buildCoachSnapshot( userId, - scopeFor([], fallbackWindow, fallbackWindow, sharedScope), + scopeFor([], fallbackWindow, fallbackWindow, sharedScope, reach), + { reach }, ); const labs = pickSection(snapshot.sections, "labs") as { recent?: Array<{ name?: string; analyte?: string }> } | undefined; @@ -590,7 +678,14 @@ async function getLabs( // The labs read is window-AGNOSTIC: a fixed trailing-12-month cutoff. So the // window it "searched" is a year regardless of the conversation's scope — // a panel from 2023 is outside it and must not read as "no labs on file". - return emptyRead(userId, "labs", subjectForTool("get_labs"), "lastYear"); + // Under a shorter lookback limit the labs read stops at the limit. + return emptyRead( + userId, + "labs", + subjectForTool("get_labs"), + clampWindow("lastYear", reach), + reach, + ); } const analyte = parsed.data.analyte?.trim().toLowerCase(); @@ -612,6 +707,7 @@ async function getIllnessRecovery( userId: string, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { // Validate the (empty) args shape for consistency; an empty object always // passes. @@ -634,7 +730,9 @@ async function getIllnessRecovery( fallbackWindow, fallbackWindow, sharedScope, + reach, ), + { reach }, ); const illness = pickSection(snapshot.sections, "illness"); const derived = pickSection(snapshot.sections, "derived"); @@ -645,7 +743,7 @@ async function getIllnessRecovery( // relevant episode. Read-only, coverage-gated (null when the engine // withholds), and the SAME engine the card + the red-flag notifier run, so // the Coach restates the numbers the user sees rather than the composite. - const illnessScores = await buildIllnessScores(userId); + const illnessScores = await buildIllnessScores(userId, undefined, reach); if ( illness === undefined && derived === undefined && @@ -655,12 +753,17 @@ async function getIllnessRecovery( ) { // No row-backed subject: illness episodes + the recovery composites are // computed / module-gated, not a window slice of one table, so there is - // nothing an availability probe could establish. Honest absence. + // nothing an availability probe could establish. Honest absence, unless + // a lookback limit is what hid the episodes. + if (isBounded(reach) && (await illnessBeyondReach(userId, reach))) { + return { present: false, reason: OUTSIDE_REACH_REASON }; + } return emptyRead( userId, "illness_recovery", subjectForTool("get_illness_recovery"), - fallbackWindow, + limitWindow(fallbackWindow, reach), + reach, ); } return { @@ -680,6 +783,7 @@ async function getWorkouts( rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = getWorkoutsArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_workouts", parsed.error); @@ -687,7 +791,14 @@ async function getWorkouts( // user has workout rows in the window. Scope the read to that single source. const snapshot = await buildCoachSnapshot( userId, - scopeFor(["workouts"], parsed.data.window, fallbackWindow, sharedScope), + scopeFor( + ["workouts"], + parsed.data.window, + fallbackWindow, + sharedScope, + reach, + ), + { reach }, ); const workouts = pickSection(snapshot.sections, "workouts"); if (workouts === undefined) { @@ -695,7 +806,8 @@ async function getWorkouts( userId, "workouts", subjectForTool("get_workouts"), - parsed.data.window ?? fallbackWindow, + limitWindow(parsed.data.window ?? fallbackWindow, reach), + reach, ); } return { present: true, data: workouts }; @@ -705,9 +817,18 @@ async function getCycle( userId: string, rawArgs: unknown, sharedScope: CoachScope | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = getCycleArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_cycle", parsed.error); + // The cycle block predicts from every logged cycle, so the snapshot leaves + // it out under any lookback limit. Say why, rather than that it is absent. + if (isBounded(reach)) { + return (await isCycleAvailableForUser(userId)) && + (await cycleRecorded(userId)) + ? { present: false, reason: OUTSIDE_REACH_REASON } + : { present: false, reason: "no_data" }; + } // The cycle block is gated INSIDE the builder by `isCycleAvailableForUser` // (the per-user toggle AND the operator switch), independent of `sources` — // so a minimal scope still surfaces it when the account tracks cycles, and a @@ -719,6 +840,7 @@ async function getCycle( const snapshot = await buildCoachSnapshot( userId, sharedScope ?? { sources: [] }, + { reach }, ); const cycle = pickSection(snapshot.sections, "cycle"); if (cycle === undefined) { @@ -730,6 +852,7 @@ async function getCycle( "cycle", subjectForTool("get_cycle"), sharedScope?.window, + reach, ); } return { present: true, data: cycle }; @@ -738,6 +861,7 @@ async function getCycle( async function getCorrelations( userId: string, rawArgs: unknown, + reach: CoachHistoryReach, ): Promise { const parsed = getCorrelationsArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_correlations", parsed.error); @@ -754,7 +878,8 @@ async function getCorrelations( // of its own. The notes below are finished sentences; some MCP clients show // them to the person verbatim rather than letting the model re-word them. const locale = await resolveLocaleForUser(userId); - const result = await readCoachCorrelations(userId, locale); + // The scan reads 180 days, or the lookback limit when that is shorter. + const result = await readCoachCorrelations(userId, locale, { reach }); if (!result.present) { return { present: false, reason: result.reason ?? "no_pattern" }; } @@ -794,11 +919,17 @@ export function scopeAdmits( * (`coachExclusions`, `admitCoachSources`), so a metric the person excluded * after a table was stored is neither listed for the model nor sent to it. * Tables of other domains (labs) answer to their module when read. + * + * The lookback limit applies the same way: a table whose range reaches past + * it (stored before the limit was set or narrowed) is not listed, because + * its line hands the model the window and the row count of history the + * Coach may no longer read. `show_result` refuses it on the same test. */ export async function admittedPriorResults(args: { userId: string; prefs: Pick; scope: CoachScope | undefined; + reach: CoachHistoryReach; prior: readonly PriorResultTurn[]; }): Promise { if (args.prior.length === 0) return []; @@ -818,7 +949,15 @@ export async function admittedPriorResults(args: { return args.prior .map((turn) => ({ ...turn, - results: turn.results.filter((meta) => admits(meta.source.domain)), + results: turn.results.filter( + (meta) => + admits(meta.source.domain) && + tableRangeWithinReach( + meta.source.window, + meta.source.period ?? "current", + args.reach, + ), + ), })) .filter((turn) => turn.results.length > 0); } @@ -830,28 +969,69 @@ export function isCoachScopeSource( return Object.hasOwn(COACH_SOURCE_MEASUREMENT_TYPES, domain); } +/** + * True when a table's whole range lies inside the lookback limit. An earlier + * period needs twice its window (previous) or a year more (a year ago), so a + * range either fits with room to spare or does not fit at all; the UTC day + * edges are close enough. + */ +export function tableRangeWithinReach( + window: CoachScopeWindow, + period: CoachResultPeriod, + reach: CoachHistoryReach, + now: Date = new Date(), +): boolean { + if (!isBounded(reach)) return true; + if (exceedsReach(window, reach)) return false; + const range = resolveTableRange({ window, period, timeZone: "UTC", now }); + return withinReach(range.from, reach, now); +} + async function getMetricTable( userId: string, rawArgs: unknown, fallbackWindow: CoachScopeWindow | undefined, sharedScope: CoachScope | undefined, turn: CoachToolTurnContext | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = getMetricTableArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs("get_metric_table", parsed.error); const { metric, granularity } = parsed.data; - const window = parsed.data.window ?? fallbackWindow ?? DEFAULT_WINDOW; + // All time becomes the lookback limit; a narrower window stays. + const window = clampWindow( + parsed.data.window ?? fallbackWindow ?? DEFAULT_WINDOW, + reach, + ); const period = window === "allTime" ? "current" : (parsed.data.period ?? "current"); const excluded = METRIC_TABLE_EXCLUDED_SOURCES[metric]; if (excluded) return { present: false, reason: excluded }; + // An earlier period is read only when all of it lies inside the limit: the + // previous window or the same window a year ago, cut short, would be a + // different comparison under the same name. + if ( + period !== "current" && + !tableRangeWithinReach(window, period, reach, turn?.now) + ) { + return { + present: false, + reason: OUTSIDE_REACH_REASON, + searchedWindow: window, + }; + } + // Same gate as every other read: a module switched off, or a metric the // person excluded from the Coach, is not read here either. const gate = await buildCoachSnapshot( userId, - sharedScope ?? { sources: [metric], window: fallbackWindow }, + sharedScope ?? { + sources: [metric], + window: limitWindow(fallbackWindow, reach), + }, + { reach }, ); if (!scopeAdmits(gate.sections, metric)) { return { @@ -880,6 +1060,7 @@ async function getMetricTable( metric, subjectForTool("get_metric_table", metric), window, + reach, ); } // An earlier window is read to be compared: read the current one too and @@ -971,6 +1152,7 @@ async function showResult( rawArgs: unknown, sharedScope: CoachScope | undefined, turn: CoachToolTurnContext | undefined, + reach: CoachHistoryReach, ): Promise { const parsed = showResultArgsSchema.safeParse(rawArgs); if (!parsed.success) return badArgs(SHOW_RESULT_TOOL_NAME, parsed.error); @@ -985,6 +1167,22 @@ async function showResult( return { present: false, reason: "unknown_result" }; } + // A stored table whose range reaches past the lookback limit set since is + // not shown again: the limit covers what the Coach shows as much as what + // it reads. + const { window: storedWindow, period: storedPeriod } = target.meta.source; + if ( + storedWindow !== undefined && + !tableRangeWithinReach( + storedWindow, + storedPeriod ?? "current", + reach, + turn.now, + ) + ) { + return { present: false, reason: OUTSIDE_REACH_REASON }; + } + // The same gate a fresh read passes, before anything is decrypted: a // metric the person has since excluded from the Coach, or left out of // this conversation's scope, is not sent to the model again either. @@ -992,7 +1190,11 @@ async function showResult( if (isCoachScopeSource(domain)) { const gate = await buildCoachSnapshot( userId, - sharedScope ?? { sources: [domain], window: target.meta.source.window }, + sharedScope ?? { + sources: [domain], + window: limitWindow(target.meta.source.window, reach), + }, + { reach }, ); if (!scopeAdmits(gate.sections, domain)) { return { present: false, reason: "unavailable_in_scope" }; @@ -1056,6 +1258,7 @@ async function withProjectedTable( fallbackWindow: CoachScopeWindow | undefined, result: CoachToolResult, turn: CoachToolTurnContext, + reach: CoachHistoryReach, ): Promise { const argWindow = rawArgs !== null && @@ -1063,7 +1266,10 @@ async function withProjectedTable( typeof (rawArgs as { window?: unknown }).window === "string" ? ((rawArgs as { window: CoachScopeWindow }).window as CoachScopeWindow) : undefined; - const window = argWindow ?? fallbackWindow ?? DEFAULT_WINDOW; + const window = clampWindow( + argWindow ?? fallbackWindow ?? DEFAULT_WINDOW, + reach, + ); try { let table: CoachResultTable | null = null; switch (name) { diff --git a/src/lib/ai/coach/tools/inventory.ts b/src/lib/ai/coach/tools/inventory.ts index 8d37e3413..0d65b1889 100644 --- a/src/lib/ai/coach/tools/inventory.ts +++ b/src/lib/ai/coach/tools/inventory.ts @@ -31,13 +31,20 @@ import { FULL_INVENTORY_SOURCE_SET, } from "./source-keys"; import { + OUTSIDE_REACH_REASON, classifyAvailability, + probeBeyondReach, probeCoachAvailability, subjectForTool, type CoachAvailabilitySubject, type CoachDomainAvailability, } from "./availability"; import { isCoachToolName } from "./definitions"; +import { + UNBOUNDED_REACH, + clampWindow, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; /** * What the record holds for a domain the WINDOW read found nothing for. Absent @@ -45,11 +52,14 @@ import { isCoachToolName } from "./definitions"; * that this window could not reach. Model-facing only — no i18n. */ export interface InventoryAvailability { - /** `outside_window` or `unavailable_in_scope` — see `availability.ts`. */ + /** + * `outside_window`, `unavailable_in_scope` or `outside_reach` — see + * `availability.ts`. An `outside_reach` row carries no figures at all. + */ state: string; - count: number; - firstDate: string; - lastDate: string; + count?: number; + firstDate?: string; + lastDate?: string; /** A window the model may re-call with to reach the rows, or null. */ reachableWithWindow: string | null; } @@ -82,6 +92,8 @@ export interface CoachDataInventory { cycleEnabled: boolean; /** The window the inventory was built against. */ window: string; + /** The person's lookback limit, or undefined when there is none. */ + lookbackLimit?: string; /** * v1.21.0 (D5-1) — the exact full-source scope this inventory's snapshot was * built against. The route threads it to the tool loop so every per-tool read @@ -114,6 +126,8 @@ const METRIC_SERIES_DOMAINS: Array<{ export async function buildCoachDataInventory( userId: string, scope: CoachScope | undefined, + /** The person's lookback limit; the probe and every row answer to it. */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { // v1.21.0 (C2-2) — probe presence against the FULL source set, not the // user's narration-cluster default. `effectiveScope` only carried the @@ -124,10 +138,13 @@ export async function buildCoachDataInventory( // figure read — it only widens what the inventory advertises. const probeScope: CoachScope = { sources: [...FULL_INVENTORY_SOURCE_SET], - window: scope?.window, + window: + scope?.window === undefined + ? undefined + : clampWindow(scope.window, reach), }; const [snapshot, cycleEnabled] = await Promise.all([ - buildCoachSnapshot(userId, probeScope), + buildCoachSnapshot(userId, probeScope, { reach }), isCycleAvailableForUser(userId), ]); const sections = snapshot.sections; @@ -223,7 +240,7 @@ export async function buildCoachDataInventory( // advertised as if it had never been recorded, and rule 2 then tells the model // not to fetch it at all. That is the reported defect: the tool never even // runs, so nothing downstream can recover the distinction. - await attachAvailability(userId, entries, scope?.window); + await attachAvailability(userId, entries, probeScope.window, reach); // restMode rides the illness section. const illness = sections.illness as { restMode?: boolean } | undefined; @@ -236,6 +253,7 @@ export async function buildCoachDataInventory( restMode, cycleEnabled, window: scopeBlock?.window ?? scope?.window ?? "last30days", + ...(reach.days !== null ? { lookbackLimit: reach.window } : {}), probeScope, }; } @@ -256,6 +274,7 @@ async function attachAvailability( userId: string, entries: InventoryEntry[], window: CoachScopeWindow | undefined, + reach: CoachHistoryReach, ): Promise { const subjects = new Map(); const keyed = new Map(); @@ -273,8 +292,13 @@ async function attachAvailability( } if (subjects.size === 0) return; let probed: Map; + let beyond: Set; try { - probed = await probeCoachAvailability(userId, subjects); + probed = await probeCoachAvailability(userId, subjects, { reach }); + // Under a limit, a domain with nothing inside it may still hold rows + // older than it. Those are named as beyond the limit, with no figures. + const unprobed = new Map([...subjects].filter(([key]) => !probed.has(key))); + beyond = await probeBeyondReach(userId, unprobed, reach); } catch (err) { // The manifest degrades to the pre-fix precision (every unreached domain // reads "absent"), which is why this is a catch and not a throw — but a @@ -290,6 +314,14 @@ async function attachAvailability( action: { name: "coach.inventory.availability" }, meta: { probed: subjects.size, stored: probed.size }, }); + for (const key of beyond) { + const entry = keyed.get(key); + if (!entry) continue; + entry.availability = { + state: OUTSIDE_REACH_REASON, + reachableWithWindow: null, + }; + } for (const [key, available] of probed) { const entry = keyed.get(key); if (!entry) continue; @@ -351,6 +383,11 @@ export function renderDataInventory(inventory: CoachDataInventory): string { 'This lists which of the user\'s data exists and how to fetch it. Never cite a figure you did not fetch with a tool this turn. Three states per domain: "present" — fetch it with the named tool. "absent" — the record holds nothing for it, ever. "outside window" / "not in scope" — the record DOES hold readings, listed with their count and date range; that is not absence, so never say the user has no data for it, and call its tool anyway (the result reports what the record holds and, when a window would reach it, which one to re-call with).', ); lines.push(`Window: ${inventory.window}.`); + if (inventory.lookbackLimit) { + lines.push( + `Lookback limit: ${inventory.lookbackLimit}. The user set how far back you may read; nothing older reaches you, whatever window a tool is called with. "BEYOND LOOKBACK" marks a domain whose readings are all older than that: do not call its tool, say older readings lie beyond the lookback set in the Coach settings, and name no figures.`, + ); + } if (inventory.restMode) { lines.push( "The user is currently in REST MODE (recovering from illness) — frame any activity guidance gently.", @@ -376,6 +413,7 @@ function renderState(entry: InventoryEntry): string { if (entry.present) return "present"; const a = entry.availability; if (!a) return "absent"; + if (a.state === OUTSIDE_REACH_REASON) return "BEYOND LOOKBACK"; // Terse by design: the preamble states once what these two states mean and // that neither is absence. Repeating that per row would double a manifest // that rides every single turn. diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index dcf4f4900..428db6191 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -23,6 +23,7 @@ * single completion — the caller falls back to the snapshot path BEFORE * entering this loop, so this is the safety floor, not the primary route. */ +import type { CoachHistoryReach } from "@/lib/ai/coach/history-reach"; import { annotate } from "@/lib/logging/context"; import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; @@ -124,6 +125,11 @@ export async function runCoachToolLoop(args: { * under one cache key — one snapshot build per turn instead of N. */ sharedScope?: CoachScope; + /** + * The person's Coach lookback limit. Required: every tool call of a chat + * turn is clamped to it, and a caller that forgot it would read past it. + */ + reach: CoachHistoryReach; ledger?: ProviderHealthLedger; /** Aborts the per-round provider calls on client disconnect. */ signal?: AbortSignal; @@ -167,6 +173,7 @@ export async function runCoachToolLoop(args: { maxTokens, fallbackWindow, sharedScope, + reach, ledger, signal, timeoutMs, @@ -261,6 +268,7 @@ export async function runCoachToolLoop(args: { rawArguments: call.arguments, fallbackWindow, sharedScope, + reach, ...(turn ? { turn } : {}), }); // v1.39.4 — the settled callback gets the table; the model never diff --git a/src/lib/ai/coach/tools/system-addendum.ts b/src/lib/ai/coach/tools/system-addendum.ts index ba298c01d..ce9091531 100644 --- a/src/lib/ai/coach/tools/system-addendum.ts +++ b/src/lib/ai/coach/tools/system-addendum.ts @@ -24,7 +24,9 @@ * the one asked about. Rule 3 also overrides base ground rule 3 for every reason * except `no_data`: "acknowledge the gap and offer to think it through with what * the user can tell you" is the right move for data the record does not hold, - * and exactly the wrong one for data it does. + * and exactly the wrong one for data it does. `outside_reach` is data + * the record holds beyond the lookback limit the person set: the model is told + * it exists, gets no figure from it, and points at the setting. */ import type { Locale } from "@/lib/i18n/config"; @@ -38,6 +40,7 @@ You have read-only retrieval tools and a DATA INVENTORY listing what the user ha - "no_data": the record holds nothing for that metric. Acknowledge it in one short sentence and offer to think it through with what the user can tell you. - "outside_window": the readings EXIST and are older than the window that was searched. "available" carries the count, the first and last date, and a per-series mean/min/max. Say what the record holds and over which dates — never that the user has no data for it. Cite only figures from "available". If "available.reachableWithWindow" names a window, call the same tool again with that window to read the real series. - "unavailable_in_scope": the readings exist and are recent, but this domain is not available in this conversation — its module may be switched off. Say that, and that the user can check it in settings. Never say the data is missing. + - "outside_reach": the readings exist and all of them are older than the lookback limit the user set for you. You are not given any figure from them, so name none: no count, no date, no value. Say that older readings lie beyond the lookback limit and that the user can widen it in the Coach settings. Never say the data is missing, and do not call the tool again with a wider window — the limit applies to every window. - "no_data_unconfirmed" or "retrieval_failed": you could not read it. Say you could not retrieve it — not that it does not exist. In every case: do NOT infer, estimate, or fabricate a value, and do NOT ask the user to supply readings the record already holds. 4. Stay on the metric the user asked about. A miss on that metric is an answer ABOUT that metric — never answer a question about one metric by reporting a different one instead. On a vague follow-up ("check again", "how were they?"), the subject is still the metric the conversation was already on. @@ -54,6 +57,7 @@ Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet, - „no_data": zu dieser Metrik liegt nichts vor. Benenne das in einem kurzen Satz und biete an, es mit dem zu durchdenken, was der Nutzer dir erzählen kann. - „outside_window": die Messwerte EXISTIEREN und sind älter als das abgefragte Zeitfenster. „available" enthält die Anzahl, das erste und letzte Datum sowie Mittelwert/Minimum/Maximum je Serie. Sage, was vorliegt und über welchen Zeitraum — niemals, dass keine Daten vorliegen. Nenne nur Zahlen aus „available". Wenn „available.reachableWithWindow" ein Fenster nennt, rufe dasselbe Tool erneut mit diesem Fenster auf und lies die echte Serie. - „unavailable_in_scope": die Messwerte existieren und sind aktuell, aber diese Domäne ist in dieser Unterhaltung nicht verfügbar — ihr Modul ist möglicherweise abgeschaltet. Sage das und dass der Nutzer es in den Einstellungen prüfen kann. Sage nie, die Daten fehlten. + - „outside_reach": die Messwerte existieren und sind alle älter als der Rückblick, den der Nutzer für dich eingestellt hat. Du bekommst keine Zahl daraus, also nenne keine: keine Anzahl, kein Datum, keinen Wert. Sage, dass ältere Werte jenseits des eingestellten Rückblicks liegen und der Nutzer ihn in den Coach-Einstellungen erweitern kann. Sage nie, die Daten fehlten, und rufe das Tool nicht mit einem weiteren Fenster erneut auf — die Grenze gilt für jedes Fenster. - „no_data_unconfirmed" oder „retrieval_failed": du konntest es nicht lesen. Sage, dass du es nicht abrufen konntest — nicht, dass es nicht existiert. In jedem Fall: leite nichts ab, schätze nichts, erfinde keinen Wert — und bitte den Nutzer nicht, Werte nachzuliefern, die längst erfasst sind. 4. Bleibe bei der Metrik, nach der gefragt wurde. Ein Fehlschlag zu dieser Metrik ist eine Antwort ÜBER diese Metrik — beantworte eine Frage zu einer Metrik niemals dadurch, dass du stattdessen über eine andere berichtest. Bei einer vagen Rückfrage („schau nochmal", „wie waren sie?") bleibt das Thema die Metrik, um die es gerade ging. diff --git a/src/lib/ai/coach/trajectory-snapshot.ts b/src/lib/ai/coach/trajectory-snapshot.ts index b189550dc..13cce83ac 100644 --- a/src/lib/ai/coach/trajectory-snapshot.ts +++ b/src/lib/ai/coach/trajectory-snapshot.ts @@ -21,6 +21,13 @@ * raw rows via the baseline reader. */ import { + UNBOUNDED_REACH, + capDays, + isBounded, + type CoachHistoryReach, +} from "./history-reach"; +import { + TRAJECTORY_DEFAULT_WINDOW_DAYS, computeTrajectory, isDerivedOk, TRAJECTORY_TYPES, @@ -88,7 +95,12 @@ export async function buildTrajectorySnapshotBlock( userId: string, profile: BaselineProfile, now: Date, + /** The Coach's lookback limit: the fit reads no further back than it. */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise | null> { + const windowOpts = isBounded(reach) + ? { windowDays: capDays(TRAJECTORY_DEFAULT_WINDOW_DAYS, reach) } + : {}; const block: Record = {}; for (const type of TRAJECTORY_TYPES) { @@ -96,7 +108,11 @@ export async function buildTrajectorySnapshotBlock( // must never sink the whole Coach turn — drop it and carry on. let derived; try { - derived = await computeTrajectory(userId, profile, { type, now }); + derived = await computeTrajectory(userId, profile, { + type, + now, + ...windowOpts, + }); } catch { continue; } diff --git a/src/lib/ai/coach/turn/context.ts b/src/lib/ai/coach/turn/context.ts index a085e2d24..3a87eff5f 100644 --- a/src/lib/ai/coach/turn/context.ts +++ b/src/lib/ai/coach/turn/context.ts @@ -37,11 +37,21 @@ import { parseCoachPrefs, type CoachPrefs, } from "@/lib/validations/coach-prefs"; +import { + clampWindow, + reachFromPrefs, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; import type { TurnConversation } from "./types"; export interface TurnContext { coachPrefs: CoachPrefs; + /** + * How far back this turn may read: the saved `defaultWindow` as a limit. + * Every snapshot build and tool call of the turn honours it. + */ + reach: CoachHistoryReach; /** * v1.22 (#89) — the upstream timeout for THIS turn's provider call. On the * streaming (local) path it is the per-idle-gap ceiling; on the buffered @@ -76,10 +86,8 @@ export async function assembleTurnContext(args: { // separately so excluded metrics never even leave the DB. // // v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved - // analysis-window preference. Merge it into the snapshot scope when - // the client didn't supply a per-conversation override; the override - // (header pill / sources rail) always wins. Keep the merge cheap so - // we don't accidentally widen narrow per-call scopes. + // analysis-window preference, merged into the snapshot scope when the + // client didn't supply a per-conversation window. const prefsRow = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -98,11 +106,17 @@ export async function assembleTurnContext(args: { prefsRow?.aiResponseTimeoutSeconds != null ? prefsRow.aiResponseTimeoutSeconds * 1000 : 180_000; - const effectiveScope = - scope?.window === undefined && coachPrefs.defaultWindow - ? { ...(scope ?? {}), window: coachPrefs.defaultWindow } - : scope; - const snapshot = await buildCoachSnapshot(userId, effectiveScope); + // The saved window is a limit as well as the default: a + // client-sent window (header pill, deep link) may narrow it, never widen it. + const reach = reachFromPrefs(coachPrefs); + const requestedWindow = scope?.window ?? coachPrefs.defaultWindow; + const effectiveScope: CoachScope = { + ...(scope ?? {}), + ...(requestedWindow !== undefined + ? { window: clampWindow(requestedWindow, reach) } + : {}), + }; + const snapshot = await buildCoachSnapshot(userId, effectiveScope, { reach }); // v1.15.20 — the user-authored "about me" self-description (Settings → // AI) rides the system prompt as a delimited, user-provided context // block. Fail-open: a missing / undecryptable text yields null and the @@ -183,7 +197,7 @@ export async function assembleTurnContext(args: { !workoutId || (await isModuleEnabled(userId, "workouts")); const workoutEvidence = isFirstTurn && workoutId && workoutsEnabled - ? await buildWorkoutEvidenceSection(userId, workoutId) + ? await buildWorkoutEvidenceSection(userId, workoutId, reach) : null; if (workoutId) { annotate({ @@ -210,6 +224,7 @@ export async function assembleTurnContext(args: { return { coachPrefs, + reach, aiResponseTimeoutMs, effectiveScope, snapshot, diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index c2d72a0f3..20d98aff9 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -217,14 +217,20 @@ export async function runTurnModel(args: { // model pulls only what it needs via the retrieval tools. The inventory // build reuses the snapshot we already computed (60s LRU), so the tools // that fire this turn share its reads. - const inventory = await buildCoachDataInventory(userId, effectiveScope); - // Earlier tables whose metric the person has since excluded are - // neither named for the model nor reachable through show_result. The + const inventory = await buildCoachDataInventory( + userId, + effectiveScope, + ctx.reach, + ); + // Earlier tables whose metric the person has since excluded, or whose + // range lies beyond the lookback limit, are neither named for the + // model nor reachable through show_result. The // scope is the one every tool of the turn reads under. const priorResults = await admittedPriorResults({ userId, prefs: ctx.coachPrefs, scope: inventory.probeScope, + reach: ctx.reach, prior: args.priorResults ?? [], }); const requestWith = (tableRules: boolean) => @@ -266,6 +272,8 @@ export async function runTurnModel(args: { // probe scope is the exact scope the inventory was built against, so the // per-tool reads land its 60s LRU entry. sharedScope: inventory.probeScope, + // The lookback limit: every tool call is clamped to it. + reach: ctx.reach, // v1.20.1 — thread the abort signal so a mid-generation disconnect tears // down the per-round provider calls instead of paying the full cost. signal, diff --git a/src/lib/ai/coach/turn/reuse-turn.ts b/src/lib/ai/coach/turn/reuse-turn.ts index 84c35a260..a4c77b336 100644 --- a/src/lib/ai/coach/turn/reuse-turn.ts +++ b/src/lib/ai/coach/turn/reuse-turn.ts @@ -34,6 +34,8 @@ import { buildMethod } from "@/lib/ai/coach/method"; import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive"; import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve"; import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; +import { readCoachReach } from "@/lib/ai/coach/history-reach-read"; +import { tableRangeWithinReach } from "@/lib/ai/coach/tools/executor"; import { createSseStream } from "@/lib/sse/create-stream"; import { persistUserTurn } from "./conversation"; @@ -60,6 +62,18 @@ async function copyStoredTable(args: { ); const entry = entries?.find((candidate) => candidate.ref === ref); if (!entry || "withheld" in entry) return null; + // A table reaching past the lookback limit set since it was made is not + // shown again; the model turn that runs instead says why. + const reach = await readCoachReach(userId); + if ( + !tableRangeWithinReach( + entry.source.window, + entry.source.period ?? "current", + reach, + ) + ) { + return null; + } if (followUp.kind === "as_chart" && entry.chart === null) return null; // Only the view changes. "As a table" keeps the chart beside the table, so // the reply can offer the chart again. diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts index 9fcad1190..882b5cfb6 100644 --- a/src/lib/ai/coach/turn/steps.ts +++ b/src/lib/ai/coach/turn/steps.ts @@ -114,6 +114,9 @@ const MISS: Readonly< no_data: { status: "empty", reason: "no_data" }, analyte_not_found: { status: "empty", reason: "no_data" }, outside_window: { status: "empty", reason: "outside_window" }, + // Beyond the lookback limit: to the person, "no readings in this window" + // is the true sentence; the limit itself is named in the answer. + outside_reach: { status: "empty", reason: "outside_window" }, module_disabled: { status: "empty", reason: "module_disabled" }, retrieval_failed: { status: "failed", reason: "retrieval_failed" }, no_data_unconfirmed: { status: "failed", reason: "retrieval_failed" }, diff --git a/src/lib/ai/coach/visits-snapshot.ts b/src/lib/ai/coach/visits-snapshot.ts index a4b524659..c73ba2a77 100644 --- a/src/lib/ai/coach/visits-snapshot.ts +++ b/src/lib/ai/coach/visits-snapshot.ts @@ -27,6 +27,11 @@ * model). `userId` is narrowed from the authenticated session by the caller and * feeds the Prisma `where` field-by-field; it is never an input. */ +import { + UNBOUNDED_REACH, + reachFloor, + type CoachHistoryReach, +} from "./history-reach"; import { prisma } from "@/lib/db"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; @@ -123,7 +128,13 @@ function toEntry(row: EncounterRow): CoachVisitEntry { export async function buildVisitsSnapshotBlock( userId: string, now: Date = new Date(), + /** + * The Coach's lookback limit: upcoming appointments are not history and + * stay; the last visit counts only when it lies inside the limit. + */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { + const floor = reachFloor(reach, now); const horizon = new Date(now.getTime() + UPCOMING_HORIZON_DAYS * 86_400_000); const practitionerInclude = { practitioner: { select: { name: true, specialty: true } }, @@ -149,7 +160,7 @@ export async function buildVisitsSnapshotBlock( userId, deletedAt: null, status: "DONE", - occurredAt: { lte: now }, + occurredAt: floor ? { lte: now, gte: floor } : { lte: now }, }, orderBy: { occurredAt: "desc" }, include: practitionerInclude, diff --git a/src/lib/ai/coach/workout-evidence-builder.ts b/src/lib/ai/coach/workout-evidence-builder.ts index 43e5de48d..3e31e77f7 100644 --- a/src/lib/ai/coach/workout-evidence-builder.ts +++ b/src/lib/ai/coach/workout-evidence-builder.ts @@ -1,4 +1,9 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + UNBOUNDED_REACH, + withinReach, + type CoachHistoryReach, +} from "./history-reach"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { @@ -66,6 +71,7 @@ interface WorkoutEvidenceDependencies { sportType: string, sourcePriorityJson: unknown, excludeWorkoutId?: string, + reach?: CoachHistoryReach, ) => Promise; buildEvidence: (input: WorkoutEvidenceInput) => Record; onSkipped: () => void; @@ -76,11 +82,18 @@ export function createWorkoutEvidenceBuilder( ): ( userId: string, workoutId: string, + /** + * The Coach's lookback limit. A workout older than it is not read, and the + * sport comparison (its own 180 days) is left out under a shorter limit + * (`buildSportContext`). + */ + reach?: CoachHistoryReach, ) => Promise | null> { - return async (userId, workoutId) => { + return async (userId, workoutId, reach = UNBOUNDED_REACH) => { try { const row = await dependencies.findWorkout(userId, workoutId); if (!row) return null; + if (!withinReach(row.startedAt, reach)) return null; const profile = await dependencies.findProfile(userId); const hrSeries = await dependencies.buildHrSeries({ @@ -103,6 +116,7 @@ export function createWorkoutEvidenceBuilder( row.sportType, profile?.sourcePriorityJson ?? null, workoutId, + reach, ); return dependencies.buildEvidence({ diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index a88e6e5ad..4f28958f1 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -16,7 +16,11 @@ import { } from "./codex-oauth"; import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "./local-host-allowlist"; -import { parseProviderChain, type ProviderChainType } from "./provider-chain"; +import { + parseProviderChain, + PROVIDER_CHAIN_TYPES, + type ProviderChainType, +} from "./provider-chain"; import type { ProviderChainResolved } from "./provider-runner"; import { providerCredentialPolicy, @@ -917,6 +921,7 @@ const NO_PRESENCE: ProviderPresence = Object.freeze({ entries: [], localOcrEnabled: false, managedBy: null, + availableTypes: [], }); /** @@ -995,6 +1000,7 @@ export function probeProviderChain( // A guardian's in-browser OCR is not the record's opt-in to use. localOcrEnabled: false, managedBy: adminKey ? "server" : null, + availableTypes: adminKey ? ["admin-openai"] : [], responseTimeoutSeconds: row.aiResponseTimeoutSeconds, }; } @@ -1003,36 +1009,43 @@ export function probeProviderChain( row.codexConnectionStatus === "connected" && !!row.codexAccessTokenEncrypted && !!row.codexRefreshTokenEncrypted; + const credentialPresent = (providerType: ProviderChainType): boolean => { + switch (providerType) { + case "codex": + return codexConnected; + case "openai": + return !!row.aiOpenaiKeyEncrypted; + case "anthropic": + return !!row.aiAnthropicKeyEncrypted; + case "local": + return !!row.aiBaseUrl; + case "openai-compatible": + return !!( + row.aiCompatBaseUrl && + (row.aiCompatModel || row.aiModel) + ); + case "admin-openai": + return adminKey; + default: + // `admin-codex` is never resolved from a stored chain entry. + return false; + } + }; + const centralCodex = + row.useCentralCodex && appSettingsCentralCodexConnected(settings); const entries: ProviderEntryPresence[] = []; for (const entry of parseProviderChain(row.aiProviderChain ?? null)) { if (!entry.enabled) continue; - const present = (() => { - switch (entry.providerType) { - case "codex": - return codexConnected; - case "openai": - return !!row.aiOpenaiKeyEncrypted; - case "anthropic": - return !!row.aiAnthropicKeyEncrypted; - case "local": - return !!row.aiBaseUrl; - case "openai-compatible": - return !!( - row.aiCompatBaseUrl && - (row.aiCompatModel || row.aiModel) - ); - case "admin-openai": - return adminKey; - default: - // `admin-codex` is never resolved from a stored chain entry. - return false; - } - })(); - if (present) entries.push(vision(entry.providerType)); + if (credentialPresent(entry.providerType)) { + entries.push(vision(entry.providerType)); + } } - if (row.useCentralCodex && appSettingsCentralCodexConnected(settings)) { + if (centralCodex) { entries.push(vision("admin-codex")); } + const availableTypes: string[] = PROVIDER_CHAIN_TYPES.filter((type) => + type === "admin-codex" ? centralCodex : credentialPresent(type), + ); if (entries.length === 0 && userRowHasProviderCredential(row, adminKey)) { // The legacy single-provider fallback, tagged as the operator's slot // the way every consumer of it tags it (the consent gate included). @@ -1051,6 +1064,7 @@ export function probeProviderChain( entries, localOcrEnabled: row.labsLocalOcrEnabled, managedBy: entries.length > 0 ? managedBy : null, + availableTypes, responseTimeoutSeconds: row.aiResponseTimeoutSeconds, }; }, diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index d031de698..d2af8a35f 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -501,13 +501,12 @@ describe("a settings change is never narrated as a health event", () => { expect(digest.score).not.toBeNull(); expect(digest.score!.delta).toBeNull(); expect(digest.score!.deltaReason).toBe("config_changed"); - // The push body is `digest.line` verbatim. It states today's number - // and nothing about its movement, which is the only honest sentence - // available across a recipe change. - expect(digest.line).toBe( - `Your health score today is ${snapshot.healthScore!.score}.`, - ); - expect(digest.line).not.toMatch(/[-−]\s?\d/); + // The push body is `digest.line` verbatim. It says the score is ready + // and nothing about its value or movement: no number reaches a locked + // phone, and no subtraction crosses a recipe change. + expect(snapshot.healthScore).not.toBeNull(); + expect(digest.line).toBe("Your health score for today is ready."); + expect(digest.line).not.toMatch(/\d/); // The rail is the other place a drop could reach a person. Nothing // in it may be narrating the score either. expect(digest.worthALook.map((item) => item.kind)).not.toContain( diff --git a/src/lib/cycle/today-verdict.ts b/src/lib/cycle/today-verdict.ts new file mode 100644 index 000000000..be98e93d4 --- /dev/null +++ b/src/lib/cycle/today-verdict.ts @@ -0,0 +1,125 @@ +/** + * Today's place in the cycle, for the dashboard's Today overview. + * + * The cycle page is the one place that decides what may be said about today + * (`resolveCycleVerdict` over the calendar grid). This read runs the same two + * steps over the same rows, narrowed to what a one-line summary needs, so the + * dashboard can never call a day something the cycle page does not: the same + * still-learning gate withholds the phase, the same ceiling stops the count + * once a cycle runs past its typical length. + * + * Read-only: no profile upsert, no forecast write. The caller has already + * confirmed the cycle module is on for the record. + */ +import { prisma } from "@/lib/db"; +import { buildCalendar, type CalendarDayLogRow } from "./engine-adapter"; +import { addDays, dayDiff } from "./day-math"; +import { goalAllowsFertileWindow } from "./dto"; +import { BBT_WINDOW, type CyclePhase } from "./types"; +import { resolveCycleVerdict } from "./verdict"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +/** How far back the grid reaches; the calendar route's default span. */ +const PAST_DAYS = 90; + +export interface TodayCycleRead { + dayOfCycle: number; + /** Null while the engine is still learning the cycle. */ + phase: CyclePhase | null; +} + +export async function readTodayCycle( + userId: string, + timezone: string, + now: Date, +): Promise { + const today = moodDateKey(now, timezone); + const from = addDays(today, -PAST_DAYS); + const logFrom = + dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0 + ? from + : addDays(today, -BBT_WINDOW); + + // A record that never set up cycle tracking has no profile: one point read + // and done, rather than three history reads that cannot be used. + const profile = await prisma.cycleProfile.findUnique({ where: { userId } }); + if (!profile) return null; + // The whole history, as the calendar route reads it: the forecast rests on + // every observed cycle, and this read must agree with the cycle page. + const [cycles, dayLogRows, nightlyTemps] = await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + prisma.cycleDayLog.findMany({ + where: { userId, deletedAt: null, date: { gte: logFrom } }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + temperatureExcluded: true, + ovulationTest: true, + cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, + intermenstrualBleeding: true, + }, + }), + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { gte: startOfLocalDayKey(addDays(today, -90), timezone) }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + ]); + if (cycles.length === 0) return null; + + // The intent fields and the presence flags never reach a phase or a day + // count; the grid carries them for the calendar's own overlay only. + const dayLogs: CalendarDayLogRow[] = dayLogRows.map((row) => ({ + ...row, + hasSymptoms: false, + hasNote: false, + sexualActivity: false, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + })); + const nights = nightlyTemps.map((m) => ({ + date: moodDateKey(m.measuredAt, timezone), + valueC: m.value, + })); + + const { prediction, days } = buildCalendar( + profile, + cycles, + dayLogs, + nights, + from, + today, + today, + goalAllowsFertileWindow(profile.goal), + ); + const verdict = resolveCycleVerdict({ + days, + today, + profile: { + typicalCycleLength: profile.typicalCycleLength, + typicalPeriodLength: profile.typicalPeriodLength, + lutealPhaseLength: profile.lutealPhaseLength, + }, + nextPeriodStart: prediction?.nextPeriodStart ?? null, + lastPeriodStart: + cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null, + }); + + if (verdict.state !== "IN_CYCLE" || verdict.dayOfCycle === null) return null; + return { dayOfCycle: verdict.dayOfCycle, phase: verdict.phase }; +} diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index 41b3533d5..c1464ebf5 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -38,6 +38,9 @@ function makeDigest(over: Partial = {}): DailyDigest { score: { value: 82, band: "good", delta: 1 }, topSignal: null, briefingLead: "Sleep looked solid last night.", + lead: { text: "Sleep looked solid last night.", source: "briefing" }, + today: [], + restMode: null, line: "Sleep looked solid last night.", worthALook: [], justIn: null, @@ -102,10 +105,10 @@ describe("buildDailyBriefingPush", () => { const digest = makeDigest({ briefingLead: null, topSignal: null, - line: "Your health score today is 82.", + line: "Your health score for today is ready.", }); const { body } = buildDailyBriefingPush(digest, t); - expect(body).toBe("Your health score today is 82."); + expect(body).toBe("Your health score for today is ready."); }); it("provisional: appends the honest sleep-pending wording", () => { diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 1ae88dcbd..5bbcadf50 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { buildDailyBriefingPush } from "@/lib/daily/daily-briefing-push"; import type { DailyBriefing } from "@/lib/ai/schema"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import { @@ -110,7 +111,13 @@ describe("buildDailyDigest — composition", () => { it("lifts score, top signal, and briefing lead from cached inputs (no recompute)", () => { const d = buildDailyDigest(input(), t); expect(d.generatedAt).toBe(NOW.toISOString()); - expect(d.score).toEqual({ value: 82, band: "good", delta: 3 }); + expect(d.score).toEqual({ + value: 82, + band: "good", + delta: 3, + steadyWeeks: null, + steadyAtLeast: false, + }); expect(d.topSignal?.headline).toBe("Blood pressure is holding steady"); expect(d.briefingLead).toBe( "Your blood pressure is holding steady this week.", @@ -130,11 +137,54 @@ describe("buildDailyDigest — composition", () => { expect(d.line).toBe("Blood pressure is holding steady"); }); - it("falls back to a deterministic score floor when no briefing exists", () => { + it("falls back to a score floor without the number when no briefing exists", () => { const d = buildDailyDigest(input({ briefing: null }), t); expect(d.topSignal).toBeNull(); expect(d.briefingLead).toBeNull(); - expect(d.line).toBe("Your health score today is 82."); + expect(d.line).toBe("Your health score for today is ready."); + }); + + it("keeps concrete values off the lock screen and in the app", () => { + // The briefing's first sentence names a reading. In the app it leads the + // hero with its number; the push line, shown on a locked phone, skips + // it for the next sentence source that carries none. + const d = buildDailyDigest( + input({ + briefing: { + ...briefing, + paragraph: + "Your blood pressure averaged 128/82 this week. Sleep dipped slightly last night.", + signalsOfDay: [ + { + ...briefing.signalsOfDay![0], + headline: "Resting pulse at 52 bpm", + }, + ], + }, + }), + t, + ); + expect(d.lead?.text).toBe("Your blood pressure averaged 128/82 this week."); + expect(d.line).not.toMatch(/\d/); + expect(d.line).toBe("Your health score for today is ready."); + // The push is built from that line, final or provisional. + expect(buildDailyBriefingPush(d, t).body).not.toMatch(/\d/); + expect( + buildDailyBriefingPush({ ...d, sleepPending: true }, t).body, + ).not.toMatch(/\d/); + }); + + it("uses a numberless headline for the push when the lead has a number", () => { + const d = buildDailyDigest( + input({ + briefing: { + ...briefing, + paragraph: "Your resting heart rate is 52 this morning.", + }, + }), + t, + ); + expect(d.line).toBe("Blood pressure is holding steady"); }); it("degrades to the honest all-clear line with neither briefing nor score", () => { @@ -1323,7 +1373,7 @@ describe("buildDailyDigest — AI text follows its capability", () => { expect(d.briefingLead).toBeNull(); expect(d.topSignal).toBeNull(); // The push line falls to the deterministic floor, never to model text. - expect(d.line).toBe("Your health score today is 82."); + expect(d.line).toBe("Your health score for today is ready."); expect(d.ai.briefing.reason).toBe(reason); }, ); @@ -1457,3 +1507,119 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place ).toEqual(["Dr. Weiss — today", "Dentist — tomorrow"]); }); }); + +describe("buildDailyDigest — Today overview", () => { + const RHR_HIGH = { + type: "RESTING_HEART_RATE", + value: 61, + low: 50, + high: 58, + direction: "above" as const, + daysAgo: 0, + valueLabel: "61 bpm", + rangeLabel: "50 to 58 bpm", + moduleKey: null, + }; + + it("skips a greeting and publishes the sentence with content as the lead", () => { + const d = buildDailyDigest( + input({ + briefing: { + ...briefing, + paragraph: + "Good morning. Your latest blood pressure is sitting in the optimal band.", + }, + }), + t, + ); + expect(d.briefingLead).toBe( + "Your latest blood pressure is sitting in the optimal band.", + ); + expect(d.lead).toEqual({ + text: "Your latest blood pressure is sitting in the optimal band.", + source: "briefing", + }); + expect(d.line).toBe(d.briefingLead); + }); + + it("without AI, leads with the strongest signal instead of the score", () => { + const d = buildDailyDigest( + input({ + ai: { + ...DIGEST_AI_AVAILABLE, + briefing: aiUnavailable("user_disabled"), + }, + vitals: [RHR_HIGH], + }), + t, + ); + expect(d.lead).toEqual({ + text: "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.", + source: "signal", + }); + // The lead names the reading in the app. The push line is shown on a + // locked phone and carries no value, so it does not take that sentence. + expect(d.line).toBe("Your health score for today is ready."); + }); + + it("publishes Rest Mode and its fact only while the illness module is on", () => { + const on = buildDailyDigest(input({ restMode: { day: 2 } }), t); + expect(on.restMode).toEqual({ day: 2 }); + expect(on.today[0]).toMatchObject({ kind: "rest_mode", value: "Day 2" }); + + const off = buildDailyDigest( + input({ restMode: { day: 2 }, modules: { illness: false } }), + t, + ); + expect(off.restMode).toBeNull(); + expect(off.today.some((f) => f.kind === "rest_mode")).toBe(false); + }); + + it("keeps an appointment on the rail and out of Today, never both", () => { + const visits = [ + { + id: "v1", + kind: "ROUTINE", + occurredAt: "2026-07-16T13:00:00.000Z", + practitionerName: "Dr. Example", + dayOffset: 0, + timeLabel: "15:00", + }, + ]; + const withRail = buildDailyDigest(input({ upcomingVisits: visits }), t); + expect(withRail.worthALook.some((i) => i.kind === "upcoming_visit")).toBe( + true, + ); + expect(withRail.today.some((f) => f.kind === "appointment")).toBe(false); + + const railOff = buildDailyDigest( + input({ + upcomingVisits: visits, + enabledHeroItemKinds: PRIORITY_ITEM_KINDS.filter( + (k) => k !== "upcoming_visit", + ), + }), + t, + ); + expect(railOff.today).toEqual([ + expect.objectContaining({ + kind: "appointment", + value: "Today 15:00, Dr. Example", + }), + ]); + }); + + it("carries the steady run on the score", () => { + const d = buildDailyDigest( + input({ scoreSteady: { weeks: 5, atLeast: false } }), + t, + ); + expect(d.score?.steadyWeeks).toBe(5); + expect(d.score?.steadyAtLeast).toBe(false); + const open = buildDailyDigest( + input({ scoreSteady: { weeks: 17, atLeast: true } }), + t, + ); + expect(open.score?.steadyAtLeast).toBe(true); + }); +}); diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 631dd4b65..498cae53f 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -21,9 +21,22 @@ vi.mock("@/lib/db", () => ({ personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) }, encounter: { findMany: vi.fn().mockResolvedValue([]) }, + healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, }, })); +vi.mock("@/lib/insights/derived/coincident-deviation", () => ({ + computeCoincidentDeviation: vi.fn(), +})); + +vi.mock("@/lib/illness/rest-mode", () => ({ + resolveRestMode: vi.fn(), +})); + +vi.mock("@/lib/cycle/today-verdict", () => ({ + readTodayCycle: vi.fn(), +})); + vi.mock("@/lib/dashboard/snapshot-read", () => ({ readDashboardSnapshotCached: vi.fn(), })); @@ -38,6 +51,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ vi.mock("@/lib/insights/derived/baseline", () => ({ readDayMeanSeries: vi.fn().mockResolvedValue({ points: [], source: "none" }), + loadBaselineProfile: vi + .fn() + .mockResolvedValue({ ageYears: 40, sex: "FEMALE", heightCm: 170 }), })); vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ @@ -65,6 +81,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { prisma } from "@/lib/db"; @@ -73,6 +90,9 @@ import { aiCapabilityToServe, } from "@/lib/ai/capabilities/gate"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; +import { resolveRestMode } from "@/lib/illness/rest-mode"; +import { readTodayCycle } from "@/lib/cycle/today-verdict"; import { AI_AVAILABLE, aiUnavailable, @@ -117,6 +137,16 @@ beforeEach(() => { vi.mocked(loadIntradayPulse).mockResolvedValue({ tension: null } as never); vi.mocked(aiCapabilityForRecord).mockResolvedValue(AI_AVAILABLE); vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); + vi.mocked(computeCoincidentDeviation).mockResolvedValue({ + status: "insufficient", + } as never); + vi.mocked(resolveRestMode).mockResolvedValue({ + active: false, + since: null, + episodeCount: 0, + episodes: [], + }); + vi.mocked(readTodayCycle).mockResolvedValue(null); }); describe("loadDailyDigest — milestone day", () => { @@ -480,3 +510,199 @@ describe("loadDailyDigest — due check-ups and today's visits", () => { expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care"); }); }); + +describe("loadDailyDigest — Today overview inputs", () => { + // Visits are left off the rail here so the Today line is the one place + // they appear; with the rail kind on, the line steps aside (see + // `today-overview.test.ts`). + const NO_VISIT_RAIL = PRIORITY_ITEM_KINDS.filter( + (kind) => kind !== "upcoming_visit", + ); + + function visit(at: string, name = "GP") { + return { + id: `v-${at}`, + kind: "ROUTINE", + occurredAt: new Date(at), + practitioner: { name }, + }; + } + + // 21:30Z on 17 July is 23:30 in Berlin (CEST): the last half hour of the + // local day. A visit at 23:45 local is still today; one at 00:30 local is + // tomorrow, although both fall on the same UTC date. + const LATE_EVENING = new Date("2026-07-17T21:30:00.000Z"); + + it("puts a 23:45 visit under today and a 00:30 visit under tomorrow, late in the evening", async () => { + vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([ + visit("2026-07-17T21:45:00.000Z", "Dr. Late"), + ] as never); + const today = await loadDailyDigest(USER, LATE_EVENING, { + enabledItemKinds: NO_VISIT_RAIL, + }); + expect(today.today).toEqual([ + expect.objectContaining({ + kind: "appointment", + value: "daily.todayFact.appointment.today", + }), + ]); + + vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([ + visit("2026-07-17T22:30:00.000Z", "Dr. Early"), + ] as never); + const tomorrow = await loadDailyDigest(USER, LATE_EVENING, { + enabledItemKinds: NO_VISIT_RAIL, + }); + expect(tomorrow.today).toEqual([ + expect.objectContaining({ + kind: "appointment", + value: "daily.todayFact.appointment.tomorrow", + }), + ]); + }); + + it("reads the same instants on a New York clock", async () => { + const NY_USER = { ...USER, timezone: "America/New_York" } as User; + vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([ + visit("2026-07-17T22:30:00.000Z"), + ] as never); + // 17:30 in New York: the 18:30 visit is still today there. + const digest = await loadDailyDigest(NY_USER, LATE_EVENING, { + enabledItemKinds: NO_VISIT_RAIL, + }); + expect(digest.today[0]?.value).toBe("daily.todayFact.appointment.today"); + }); + + it("renders the visit time in the profile zone and hour-cycle preference", async () => { + const { getServerTranslator } = + await import("@/lib/i18n/server-translator"); + const seen: Array | undefined> = []; + vi.mocked(getServerTranslator).mockReturnValueOnce({ + t: (key: string, params?: Record) => { + if (key === "daily.todayFact.appointment.today") seen.push(params); + return key; + }, + } as never); + vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([ + visit("2026-07-17T21:45:00.000Z", "Dr. Late"), + ] as never); + await loadDailyDigest( + { ...USER, timeFormat: "H12" } as User, + LATE_EVENING, + { enabledItemKinds: NO_VISIT_RAIL }, + ); + expect(seen[0]).toEqual({ time: "11:45 PM", what: "Dr. Late" }); + }); + + it("passes Rest Mode through, counted on the reader's own calendar", async () => { + vi.mocked(resolveRestMode).mockResolvedValueOnce({ + active: true, + // 22:00Z on 14 July is already 15 July in Berlin: day 3 on 17 July. + since: "2026-07-14T22:00:00.000Z", + episodeCount: 1, + episodes: [], + }); + const digest = await loadDailyDigest(USER, NOW); + expect(digest.restMode).toEqual({ day: 3 }); + expect(digest.today[0]).toMatchObject({ kind: "rest_mode" }); + }); + + it("does not read Rest Mode or the cycle while their modules are off", async () => { + vi.mocked(resolveModuleMap).mockResolvedValueOnce({ + illness: false, + cycle: false, + } as never); + const digest = await loadDailyDigest(USER, NOW); + expect(resolveRestMode).not.toHaveBeenCalled(); + expect(readTodayCycle).not.toHaveBeenCalled(); + expect(digest.restMode).toBeNull(); + }); + + it("reads the cycle once per extras cell, and again after a cycle write", async () => { + // The digest polls every 120 s. The cycle read (the whole cycle history, + // 90 days of day logs and wrist temperature) rides the cached cell, which + // a cycle write hard-evicts. + await loadDailyDigest(USER, NOW); + await loadDailyDigest(USER, NOW); + expect(readTodayCycle).toHaveBeenCalledTimes(1); + invalidateUserHealthContext(USER.id); + await loadDailyDigest(USER, NOW); + expect(readTodayCycle).toHaveBeenCalledTimes(2); + }); + + it("reads the cycle in the profile timezone and keeps a failed read quiet", async () => { + vi.mocked(readTodayCycle).mockRejectedValueOnce(new Error("boom")); + const digest = await loadDailyDigest(USER, NOW); + expect(readTodayCycle).toHaveBeenCalledWith(USER.id, "Europe/Berlin", NOW); + expect(digest.today.some((f) => f.kind === "cycle")).toBe(false); + }); + + it("builds the deterministic lead from the vitals read when AI is off", async () => { + vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({ + ...SNAPSHOT, + body: { + ...SNAPSHOT.body, + briefingAi: aiUnavailable("user_disabled"), + }, + } as never); + vi.mocked(aiCapabilityToServe).mockResolvedValue( + aiUnavailable("user_disabled"), + ); + vi.mocked(computeCoincidentDeviation).mockResolvedValueOnce({ + status: "ok", + value: { + fired: false, + contributing: [], + day: "2026-07-17", + illnessExplained: false, + vitals: [ + { + type: "RESTING_HEART_RATE", + value: 61, + center: 54, + low: 50, + high: 58, + outside: true, + direction: "above", + daysAgo: 0, + }, + ], + }, + } as never); + const digest = await loadDailyDigest(USER, NOW); + expect(digest.lead).toEqual({ + text: "daily.lead.vitalOutside.above", + source: "signal", + }); + expect(digest.briefingLead).toBeNull(); + }); + + it("publishes how long the score has held", async () => { + vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({ + ...SNAPSHOT, + body: { + ...SNAPSHOT.body, + healthScore: { + score: 94, + band: "GREEN", + delta: null, + deltaReason: "below_noise_floor", + }, + }, + } as never); + const rows = Array.from({ length: 22 }, (_, i) => ({ + dayKey: new Date(Date.parse("2026-07-17T12:00:00Z") - i * 86_400_000) + .toISOString() + .slice(0, 10), + composite: 94, + band: "GREEN", + scoreVersion: 4, + composition: ["BLOOD_PRESSURE"], + })); + vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValueOnce( + rows as never, + ); + const digest = await loadDailyDigest(USER, NOW); + expect(digest.score?.steadyWeeks).toBe(3); + }); +}); diff --git a/src/lib/daily/__tests__/score-steady.test.ts b/src/lib/daily/__tests__/score-steady.test.ts new file mode 100644 index 000000000..40f17caeb --- /dev/null +++ b/src/lib/daily/__tests__/score-steady.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; + +import { + STEADY_READ_DAYS, + steadyRun, + type StoredScoreDay, +} from "@/lib/daily/score-steady"; +import { addDays } from "@/lib/cycle/day-math"; + +const TODAY = "2026-10-04"; + +/** `n` consecutive days ending today, all at `composite`. */ +function run( + n: number, + composite = 94, + over: Partial = {}, +): StoredScoreDay[] { + return Array.from({ length: n }, (_, i) => ({ + dayKey: addDays(TODAY, -i), + composite, + band: "GREEN", + scoreVersion: 4, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + ...over, + })); +} + +/** The run's weeks against a ring showing `value` in the green band. */ +function steadyWeeks( + rows: StoredScoreDay[], + today: string, + value: number, +): number | null { + return steadyRun(rows, today, { value, band: "GREEN" })?.weeks ?? null; +} + +describe("steadyRun", () => { + it("counts whole weeks of a held score", () => { + expect(steadyWeeks(run(29), TODAY, 94)).toBe(4); + }); + + it("stays quiet below two weeks", () => { + expect(steadyWeeks(run(13), TODAY, 94)).toBeNull(); + expect(steadyWeeks(run(15), TODAY, 94)).toBe(2); + }); + + it("tolerates two points of wobble, not three", () => { + const rows = run(29).map((r, i) => ({ + ...r, + composite: i % 2 === 0 ? 94 : 92, + })); + expect(steadyWeeks(rows, TODAY, 94)).toBe(4); + const moved = run(29).map((r, i) => + i === 10 ? { ...r, composite: 90 } : r, + ); + expect(steadyWeeks(moved, TODAY, 94)).toBeNull(); + }); + + it("ends the run at a band change, a recipe change or a new algorithm", () => { + const band = run(29).map((r, i) => (i >= 15 ? { ...r, band: "AMBER" } : r)); + expect(steadyWeeks(band, TODAY, 94)).toBe(2); + const recipe = run(29).map((r, i) => + i >= 15 ? { ...r, composition: ["BLOOD_PRESSURE"] } : r, + ); + expect(steadyWeeks(recipe, TODAY, 94)).toBe(2); + const version = run(29).map((r, i) => + i >= 15 ? { ...r, scoreVersion: 3 } : r, + ); + expect(steadyWeeks(version, TODAY, 94)).toBe(2); + }); + + it("does not bridge a gap longer than three days", () => { + const rows = run(29).filter((_, i) => i < 15 || i > 19); + expect(steadyWeeks(rows, TODAY, 94)).toBe(2); + const short = run(29).filter((_, i) => i < 15 || i > 16); + expect(steadyWeeks(short, TODAY, 94)).toBe(4); + }); + + it("says nothing when the record stopped before today", () => { + const stale = run(40).filter((r) => r.dayKey <= addDays(TODAY, -5)); + expect(steadyWeeks(stale, TODAY, 94)).toBeNull(); + }); + + it("says nothing when the ring shows a number the record does not", () => { + expect(steadyWeeks(run(29), TODAY, 88)).toBeNull(); + }); + + it("reads rows in any order", () => { + expect(steadyWeeks(run(29).reverse(), TODAY, 94)).toBe(4); + expect(steadyWeeks([], TODAY, 94)).toBeNull(); + }); + + it("says nothing when the ring's band is not the record's", () => { + // Same number, but the worst pillar pulled the live band down overnight: + // the score did not hold where it is. + expect(steadyRun(run(29), TODAY, { value: 94, band: "yellow" })).toBeNull(); + // Case is the enum's, not a difference. + expect(steadyRun(run(29), TODAY, { value: 94, band: "green" })?.weeks).toBe( + 4, + ); + }); + + it("says nothing when the ring is computed another way than the record", () => { + expect( + steadyRun(run(29), TODAY, { value: 94, band: "GREEN", scoreVersion: 5 }), + ).toBeNull(); + expect( + steadyRun(run(29), TODAY, { + value: 94, + band: "GREEN", + composition: ["BLOOD_PRESSURE"], + }), + ).toBeNull(); + }); + + it("says at least, not since, when the run reaches past what was read", () => { + const all = run(STEADY_READ_DAYS + 1); + const out = steadyRun(all, TODAY, { value: 94, band: "GREEN" }); + expect(out).toEqual({ + weeks: Math.floor(STEADY_READ_DAYS / 7), + atLeast: true, + }); + // A run that ended inside the read has a start and says so. + const ended = all.map((r, i) => (i >= 40 ? { ...r, composite: 80 } : r)); + expect(steadyRun(ended, TODAY, { value: 94, band: "GREEN" })).toEqual({ + weeks: 5, + atLeast: false, + }); + }); +}); diff --git a/src/lib/daily/__tests__/today-overview.test.ts b/src/lib/daily/__tests__/today-overview.test.ts new file mode 100644 index 000000000..db11291ec --- /dev/null +++ b/src/lib/daily/__tests__/today-overview.test.ts @@ -0,0 +1,619 @@ +import { describe, it, expect } from "vitest"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; +import type { DailyBriefing } from "@/lib/ai/schema"; +import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; +import type { PriorityItem } from "@/lib/daily/priority-item"; +import { + buildTodayOverview, + firstSubstantiveSentence, + isGreetingOnly, + MAX_TODAY_FACTS, + type TodayOverviewInput, + type TodayVital, +} from "@/lib/daily/today-overview"; + +const t = getServerTranslator("en").t; + +function meds(over: Partial = {}): MedsTodayBlock { + return { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + nextDueMedicationId: null, + ...over, + }; +} + +function vital(over: Partial = {}): TodayVital { + return { + type: "RESTING_HEART_RATE", + value: 54, + low: 50, + high: 58, + direction: "in", + daysAgo: 0, + valueLabel: "54 bpm", + rangeLabel: "50 to 58 bpm", + moduleKey: null, + ...over, + }; +} + +/** A quiet day with nothing at all to say. */ +function input(over: Partial = {}): TodayOverviewInput { + return { + locale: "en", + modules: {}, + reactionLine: null, + briefing: null, + scoreValue: 94, + medsToday: meds(), + visits: [], + rail: [], + restMode: null, + sleep: null, + vitals: [], + cycle: null, + ...over, + }; +} + +const briefing = (paragraph: string, headline?: string): DailyBriefing => ({ + paragraph, + signalsOfDay: headline + ? [ + { + sourceMetric: "pulse", + tone: "info", + headline, + nudge: "", + delta: null, + }, + ] + : [], + keyFindings: [], +}); + +/** Every fact the builder can produce, all at once. */ +function busyDay(over: Partial = {}): TodayOverviewInput { + return input({ + restMode: { day: 3 }, + medsToday: meds({ scheduledToday: 3, takenToday: 1 }), + visits: [ + { id: "v1", dayOffset: 1, timeLabel: "09:30", what: "Dr. Example" }, + ], + sleep: { minutes: 440, usualMinutes: 430 }, + vitals: [ + vital(), + vital({ type: "HEART_RATE_VARIABILITY", valueLabel: "48 ms" }), + ], + cycle: { dayOfCycle: 12, phase: "FOLLICULAR" }, + // An AI lead keeps every fact in the list; the deterministic lead would + // take one of them. + briefing: briefing("Resting heart rate held at 54 bpm overnight."), + ...over, + }); +} + +describe("greeting detection", () => { + it.each([ + "Good morning.", + "Good evening!", + "Hello there.", + "Guten Morgen.", + "Hallo!", + "Buenos días.", + "Bonjour !", + "Buongiorno.", + "Dzień dobry.", + "좋은 아침이에요.", + "안녕하세요.", + "Welcome back.", + "Nice.", + ])("treats %j as a greeting", (sentence) => { + expect(isGreetingOnly(sentence)).toBe(true); + }); + + it.each([ + "Good morning, your resting heart rate is 52.", + "High blood pressure readings eased this week.", + "Morning blood pressure sat in the optimal band all week.", + "Your sleep landed right around your target again.", + ])("treats %j as content", (sentence) => { + expect(isGreetingOnly(sentence)).toBe(false); + }); +}); + +describe("firstSubstantiveSentence", () => { + it("skips a greeting and leads with the sentence that says something", () => { + expect( + firstSubstantiveSentence( + "Good morning. Your latest blood pressure is sitting in the optimal band.", + 94, + ), + ).toBe("Your latest blood pressure is sitting in the optimal band."); + }); + + it("skips a sentence that only repeats the ring's number", () => { + expect( + firstSubstantiveSentence( + "Your health score is 94. Sleep dipped slightly last night.", + 94, + ), + ).toBe("Sleep dipped slightly last night."); + }); + + it("keeps a sentence where the score's digits are part of another figure", () => { + // Score 52, and a sleep duration that happens to end in 52 minutes. + expect( + firstSubstantiveSentence("You slept 7 h 52 min, a solid night.", 52), + ).toBe("You slept 7 h 52 min, a solid night."); + expect( + firstSubstantiveSentence("Your resting heart rate is 52 bpm.", 52), + ).toBe("Your resting heart rate is 52 bpm."); + expect( + firstSubstantiveSentence("You walked 52 minutes more than usual.", 52), + ).toBe("You walked 52 minutes more than usual."); + }); + + it("still skips the score sentence in every shipped language", () => { + for (const sentence of [ + "Your health score is 52.", + "Dein Gesundheitsscore heute liegt bei 52.", + "Tu puntuación de salud hoy es 52.", + "Ton score de santé aujourd'hui est de 52.", + "Il tuo punteggio di salute oggi è 52.", + "오늘 건강 점수는 52점이에요.", + "Twój dzisiejszy wynik zdrowia to 52.", + "You are at 52/100 today.", + ]) { + expect( + firstSubstantiveSentence(`${sentence} Sleep dipped last night.`, 52), + sentence, + ).toBe("Sleep dipped last night."); + } + }); + + it("keeps decimals inside one sentence", () => { + expect( + firstSubstantiveSentence("You slept 7.5 hours, a solid night.", null), + ).toBe("You slept 7.5 hours, a solid night."); + }); + + it("returns null when nothing qualifies", () => { + expect(firstSubstantiveSentence("Good morning. Hi!", 94)).toBeNull(); + expect(firstSubstantiveSentence("", 94)).toBeNull(); + expect(firstSubstantiveSentence(null, 94)).toBeNull(); + }); +}); + +describe("lead precedence", () => { + it("prefers the reaction line over the briefing", () => { + const o = buildTodayOverview( + input({ + reactionLine: "A solid night, deeper than your recent stretch.", + briefing: briefing("Your week is trending steady."), + }), + t, + ); + expect(o.lead).toEqual({ + text: "A solid night, deeper than your recent stretch.", + source: "reaction", + }); + }); + + it("drops a greeting-only reaction line and falls through", () => { + const o = buildTodayOverview( + input({ + reactionLine: "Good morning!", + briefing: briefing("Your week is trending steady."), + }), + t, + ); + expect(o.lead).toEqual({ + text: "Your week is trending steady.", + source: "briefing", + }); + }); + + it("never leads with 'Good morning.' (the defect this replaces)", () => { + const o = buildTodayOverview( + input({ + briefing: briefing( + "Good morning. Your latest blood pressure is sitting in the optimal band.", + ), + }), + t, + ); + expect(o.lead?.text).toBe( + "Your latest blood pressure is sitting in the optimal band.", + ); + expect(o.briefingLead).toBe(o.lead?.text); + }); + + it("leads with the top-signal headline when the paragraph has nothing", () => { + const o = buildTodayOverview( + input({ + briefing: briefing("Your health score is 94.", "Pulse is settling"), + }), + t, + ); + expect(o.lead).toEqual({ text: "Pulse is settling", source: "briefing" }); + }); + + it("builds a deterministic lead when no AI text exists", () => { + const o = buildTodayOverview( + input({ vitals: [vital(), vital({ type: "HEART_RATE_VARIABILITY" })] }), + t, + ); + expect(o.lead).toEqual({ + text: "All 2 of your latest vitals sit inside their usual range.", + source: "signal", + }); + }); + + it("has no lead, and no filler, when there is nothing to say", () => { + const o = buildTodayOverview(input(), t); + expect(o.lead).toBeNull(); + expect(o.today).toEqual([]); + }); +}); + +describe("deterministic lead — strongest signal", () => { + it("names the vital furthest outside its range, with its numbers", () => { + const o = buildTodayOverview( + input({ + vitals: [ + vital({ value: 60, direction: "above", valueLabel: "60 bpm" }), + vital({ + type: "HEART_RATE_VARIABILITY", + value: 20, + low: 40, + high: 60, + direction: "below", + valueLabel: "20 ms", + rangeLabel: "40 to 60 ms", + }), + ], + }), + t, + ); + // HRV is a full band width below; resting HR a quarter above. + expect(o.lead?.text).toBe( + "Heart-rate variability is at 20 ms, below your usual range of 40 to 60 ms, and 1 more vital is outside its range too.", + ); + expect(o.lead?.source).toBe("signal"); + }); + + it("says a single out-of-range vital plainly", () => { + const o = buildTodayOverview( + input({ + vitals: [ + vital({ value: 61, direction: "above", valueLabel: "61 bpm" }), + ], + }), + t, + ); + expect(o.lead?.text).toBe( + "Resting heart rate is at 61 bpm, above your usual range of 50 to 58 bpm.", + ); + }); + + it("puts an unusual night ahead of calm vitals", () => { + const o = buildTodayOverview( + input({ + sleep: { minutes: 330, usualMinutes: 440 }, + vitals: [vital(), vital({ type: "PULSE" })], + }), + t, + ); + expect(o.lead?.text).toBe( + "Last night you slept 5h 30m, 1h 50m less than usual.", + ); + // The night is the lead, so it is not repeated as a fact; the vitals stay. + expect(o.today.map((f) => f.kind)).toEqual(["vitals"]); + }); + + it("removes the fact it was built from, and only that one", () => { + const o = buildTodayOverview( + input({ + vitals: [vital(), vital({ type: "PULSE" })], + sleep: { minutes: 440, usualMinutes: 430 }, + }), + t, + ); + expect(o.lead?.source).toBe("signal"); + expect(o.today.map((f) => f.kind)).toEqual(["sleep"]); + }); + + it("reads an ordinary night when there is nothing else", () => { + const o = buildTodayOverview( + input({ sleep: { minutes: 440, usualMinutes: 430 } }), + t, + ); + expect(o.lead?.text).toBe( + "Last night you slept 7h 20m, close to your usual.", + ); + expect(o.today).toEqual([]); + }); + + it("ignores a vital that is not from today or yesterday", () => { + const o = buildTodayOverview( + input({ + vitals: [vital({ daysAgo: 4, direction: "above", value: 70 })], + }), + t, + ); + expect(o.lead).toBeNull(); + expect(o.today).toEqual([]); + }); +}); + +describe("AI on and off", () => { + it("keeps the same facts with and without AI text", () => { + const withAi = buildTodayOverview(busyDay({ restMode: null }), t); + const withoutAi = buildTodayOverview( + busyDay({ restMode: null, briefing: null }), + t, + ); + expect(withAi.lead?.source).toBe("briefing"); + expect(withoutAi.lead?.source).toBe("signal"); + // Without AI the deterministic lead takes the vitals line; every other + // fact is identical. + expect(withAi.today.filter((f) => f.kind !== "vitals")).toEqual( + withoutAi.today, + ); + }); +}); + +describe("Today facts", () => { + it("orders facts by priority and caps them at five, never padding", () => { + const o = buildTodayOverview(busyDay(), t); + expect(o.today.map((f) => f.kind)).toEqual([ + "rest_mode", + "medications", + "appointment", + "sleep", + "vitals", + ]); + expect(o.today).toHaveLength(MAX_TODAY_FACTS); + }); + + it("states values without asking for anything", () => { + const o = buildTodayOverview(busyDay(), t); + const byKind = Object.fromEntries(o.today.map((f) => [f.kind, f])); + expect(byKind.rest_mode.value).toBe("Day 3"); + expect(byKind.medications.value).toBe("1 of 3 taken"); + expect(byKind.appointment.value).toBe("Tomorrow 09:30, Dr. Example"); + expect(byKind.sleep.value).toBe("7h 20m, close to your usual"); + expect(byKind.vitals.value).toBe("2 checked, all in your range"); + for (const fact of o.today) { + expect(fact.value).not.toMatch(/\b(please|log|add|record|measure)\b/i); + } + }); + + it("shows the cycle once there is room for it", () => { + const o = buildTodayOverview(busyDay({ restMode: null }), t); + expect(o.today.at(-1)).toMatchObject({ + kind: "cycle", + value: "Follicular, day 12", + href: "/cycle", + }); + }); + + it("says only the day while the cycle phase is withheld", () => { + const o = buildTodayOverview( + input({ cycle: { dayOfCycle: 4, phase: null } }), + t, + ); + expect(o.today).toEqual([ + expect.objectContaining({ kind: "cycle", value: "Day 4" }), + ]); + }); + + it("names the out-of-range vital in the vitals line", () => { + const o = buildTodayOverview( + input({ + briefing: briefing("Your week is trending steady."), + vitals: [ + vital({ direction: "above", value: 61 }), + vital({ type: "PULSE" }), + ], + }), + t, + ); + expect(o.today).toEqual([ + expect.objectContaining({ + kind: "vitals", + value: "Resting heart rate above your range", + }), + ]); + }); + + it("skips medications when nothing is scheduled today", () => { + const o = buildTodayOverview( + input({ medsToday: meds({ activeCount: 2, scheduledToday: 0 }) }), + t, + ); + expect(o.today).toEqual([]); + }); +}); + +describe("module gating", () => { + const cases: Array<[string, TodayOverviewInput["modules"], string]> = [ + ["illness", { illness: false }, "rest_mode"], + ["medications", { medications: false }, "medications"], + ["sleep", { sleep: false }, "sleep"], + ["cycle", { cycle: false }, "cycle"], + ]; + + it.each(cases)( + "drops the %s fact when its module is off", + (_name, modules, kind) => { + const on = buildTodayOverview(busyDay({ restMode: null }), t); + const all = buildTodayOverview( + busyDay(kind === "rest_mode" ? {} : { restMode: null }), + t, + ); + const off = buildTodayOverview( + busyDay({ + modules, + ...(kind === "rest_mode" ? {} : { restMode: null }), + }), + t, + ); + expect([...on.today, ...all.today].some((f) => f.kind === kind)).toBe( + true, + ); + expect(off.today.some((f) => f.kind === kind)).toBe(false); + }, + ); + + it("never builds a sleep lead with the sleep module off", () => { + const o = buildTodayOverview( + input({ + modules: { sleep: false }, + sleep: { minutes: 300, usualMinutes: 440 }, + }), + t, + ); + expect(o.lead).toBeNull(); + }); + + it("leaves out a vital whose module is off", () => { + const o = buildTodayOverview( + input({ + modules: { glucose: false }, + vitals: [ + vital({ + type: "BLOOD_GLUCOSE", + moduleKey: "glucose", + direction: "above", + value: 200, + }), + ], + }), + t, + ); + expect(o.lead).toBeNull(); + expect(o.today).toEqual([]); + }); + + it("keeps everything with every module on", () => { + const o = buildTodayOverview(busyDay({ restMode: null }), t); + expect(o.today.map((f) => f.kind)).toEqual([ + "medications", + "appointment", + "sleep", + "vitals", + "cycle", + ]); + }); +}); + +describe("appointments", () => { + const rail = (kind: PriorityItem["kind"]): PriorityItem[] => [ + { kind, title: "x", status: "info", actions: [] }, + ]; + + it("shows today before tomorrow", () => { + const o = buildTodayOverview( + input({ + visits: [ + { id: "b", dayOffset: 1, timeLabel: "08:00", what: "Dentist" }, + { id: "a", dayOffset: 0, timeLabel: "16:15", what: "GP" }, + ], + }), + t, + ); + expect(o.today[0].value).toBe("Today 16:15, GP"); + }); + + it("says nothing about the day after tomorrow", () => { + const o = buildTodayOverview( + input({ + visits: [{ id: "a", dayOffset: 2, timeLabel: "10:00", what: "GP" }], + }), + t, + ); + expect(o.today).toEqual([]); + }); + + it("counts further visits on the same day", () => { + const o = buildTodayOverview( + input({ + visits: [ + { id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" }, + { id: "b", dayOffset: 0, timeLabel: "11:00", what: "Lab" }, + ], + }), + t, + ); + expect(o.today[0].value).toBe("Today 09:00, GP +1"); + }); + + it("does not repeat a visit the rail already shows", () => { + const o = buildTodayOverview( + input({ + visits: [{ id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" }], + rail: rail("upcoming_visit"), + }), + t, + ); + expect(o.today).toEqual([]); + }); + + it("still shows it when the rail holds something else", () => { + const o = buildTodayOverview( + input({ + visits: [{ id: "a", dayOffset: 0, timeLabel: "09:00", what: "GP" }], + rail: rail("dose_window"), + }), + t, + ); + expect(o.today).toHaveLength(1); + }); +}); + +describe("locales", () => { + it.each(["de", "es", "fr", "it", "pl", "ko"] as Locale[])( + "resolves every %s string from its own bundle", + (locale) => { + const tl = getServerTranslator(locale).t; + const o = buildTodayOverview( + busyDay({ locale, restMode: null, briefing: null }), + tl, + ); + const en = buildTodayOverview( + busyDay({ restMode: null, briefing: null }), + t, + ); + expect(o.lead?.text).toBeTruthy(); + expect(o.lead?.text).not.toBe(en.lead?.text); + for (const fact of o.today) { + expect(fact.label).not.toMatch(/^daily\./); + expect(fact.value).not.toMatch(/daily\./); + } + }, + ); + + it("uses the Polish few-form for two to four", () => { + const tl = getServerTranslator("pl").t; + const o = buildTodayOverview( + input({ + locale: "pl", + vitals: [vital(), vital({ type: "PULSE" }), vital({ type: "WEIGHT" })], + }), + tl, + ); + expect(o.lead?.text).toBe( + "Wszystkie 3 ostatnie parametry życiowe mieszczą się w zwykłym zakresie.", + ); + }); +}); diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index a64266730..003854f8d 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -16,10 +16,11 @@ * `ok` ledger row; the frequency-cap check suppresses the second — one push per * user per local day, no "you haven't opened the app" second push, ever. * - * Privacy + calm posture (load-bearing): the body is `digest.line`, which is - * non-clinical by construction (a briefing lead sentence, the top signal's - * headline, a 0–100 score statement, or the honest all-clear — never a raw BP / - * glucose figure). It is dispatched NON-URGENT, so it never escalates to a + * Privacy + calm posture (load-bearing): the body is `digest.line`, which + * carries no number by construction (a briefing lead sentence or the top + * signal's headline without a figure in it, "your score is ready", or the + * honest all-clear — never a reading, a dose or the score itself, since the + * push lands on a locked phone). It is dispatched NON-URGENT, so it never escalates to a * Focus-bypassing / time-sensitive delivery — a concerning reading always stays * on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning * window, which is the quiet-hours guarantee for this nudge. diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index ad03736fd..7a9c6cdb8 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -55,13 +55,21 @@ import { COACH_CHECKIN_ADJUST_INTENT, } from "@/lib/daily/coach-checkin-intents"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; +import type { Locale } from "@/lib/i18n/config"; +import type { SteadyRun } from "@/lib/daily/score-steady"; +import { + buildTodayOverview, + type TodayCycle, + type TodayFact, + type TodayLead, + type TodayRestMode, + type TodaySleep, + type TodayVital, +} from "@/lib/daily/today-overview"; /** At most three rail items — a glance, never a wall (§2.5, never padded). */ export const MAX_WORTH_A_LOOK = 3; -/** Trim a briefing-lead sentence to a lock-screen-friendly length. */ -const MAX_LINE_LENGTH = 160; - const MS_PER_DAY = 86_400_000; /** @@ -123,6 +131,19 @@ export interface DailyDigestScore { * broad one. Omitted by older cached digests, like its siblings above. */ scoreBasis?: ScoreBasis; + /** + * Whole weeks the score has held where it is (same band, within two + * points, computed the same way), read off the stored daily scores. Null + * when it has not held for two weeks, when the stored record does not + * reach today, or when the score moved. Optional so older cached digests + * stay valid. + */ + steadyWeeks?: number | null; + /** + * True when the run reaches back past everything the read covered, so + * `steadyWeeks` is a lower bound ("at least"), not the run's start. + */ + steadyAtLeast?: boolean; } /** A broken integration, deterministically derived from `IntegrationStatus`. */ @@ -170,6 +191,12 @@ export interface DailyDigestUpcomingVisit { * builder never needs the timezone. */ dayOffset: number; + /** + * The start time on the person's own clock, in their profile timezone, + * rendered by the IO seam ("09:30"). Optional so a fixture that predates + * the Today overview stays valid; the overview skips a visit without it. + */ + timeLabel?: string; } /** @@ -370,6 +397,21 @@ export interface DailyDigestInput { * valid; the builder treats a missing value as "nothing landed today". */ arrivals?: readonly DailyDigestArrival[]; + /** + * The reader's locale, for the overview's count-bearing strings. Optional + * so a fixture that predates the overview stays valid (English then). + */ + locale?: Locale; + /** Rest Mode, when an illness episode is active (illness module). */ + restMode?: TodayRestMode | null; + /** Last night's sleep, when it is in the record (sleep module). */ + sleepLastNight?: TodaySleep | null; + /** Each banded vital's standing against its personal range. */ + vitals?: readonly TodayVital[]; + /** Today's place in the cycle (cycle module). */ + cycle?: TodayCycle | null; + /** How long the score has held; see `DailyDigestScore.steadyWeeks`. */ + scoreSteady?: SteadyRun | null; } export interface DailyDigest { @@ -387,8 +429,27 @@ export interface DailyDigest { * fan-out on every read, exactly the warm-on-mount load the plan forbids. */ topSignal: DailyBriefingSignal | null; - /** First sentence of the cached briefing paragraph, read-only. */ + /** + * The first sentence of the cached briefing paragraph that says something: + * a greeting ("Good morning.") or a sentence that only repeats the score is + * skipped. Read-only. + */ briefingLead: string | null; + /** + * The hero's lead line, resolved: the reaction line or a briefing sentence + * while AI text is available, otherwise a deterministic sentence about the + * day's strongest signal. Null when there is nothing to say. + */ + lead: TodayLead | null; + /** + * The Today overview: up to five statements about the day (Rest Mode, + * medications, an appointment today or tomorrow, last night, vitals, + * cycle), in priority order, never padded. A narrow screen shows the + * first four. + */ + today: TodayFact[]; + /** Whether an illness episode is active (Rest Mode), and since which day. */ + restMode: TodayRestMode | null; /** The push / lock-screen line (cached-AI lead with a deterministic floor). */ line: string; /** Bounded 0–3 rail items, never padded. */ @@ -417,19 +478,6 @@ export interface DailyDigest { ai: DailyDigestAi; } -/** First sentence of a paragraph, trimmed; null when empty. */ -function firstSentence(paragraph: string | null | undefined): string | null { - if (!paragraph) return null; - const trimmed = paragraph.trim(); - if (!trimmed) return null; - const match = trimmed.match(/^[\s\S]*?[.!?](?:\s|$)/); - let sentence = (match ? match[0] : trimmed).trim(); - if (sentence.length > MAX_LINE_LENGTH) { - sentence = `${sentence.slice(0, MAX_LINE_LENGTH - 1).trimEnd()}…`; - } - return sentence.length > 0 ? sentence : null; -} - /** Title-case a lowercase integration token for user-facing copy. */ function integrationLabel(token: string): string { if (!token) return token; @@ -961,21 +1009,38 @@ function buildEcgNewRecordingItem( }; } +/** Whether a sentence carries a figure: a reading, a dose, a score. */ +const HAS_NUMBER = /\p{Nd}/u; + /** - * The push / lock-screen line: prefer the warmer cached briefing lead, fall - * back to the top signal's headline, then a deterministic score floor, then - * the honest all-clear. NEVER a fresh AI call — every branch reads cache or a - * deterministic string, so a keyless self-hoster still gets a first-class line. + * The push / lock-screen line: the warmer cached briefing lead first, then + * the top signal's headline, then the deterministic sentence about the day's + * strongest signal, then a score floor, then the honest all-clear. The + * reaction line is left out on purpose: it answers something that landed + * during the day, and the morning push is sent before anything has. NEVER a + * fresh AI call — every branch reads cache or a deterministic string, so a + * keyless self-hoster still gets a first-class line. + * + * The line is shown on a locked phone, so it carries no number: a candidate + * with a figure in it is passed over for the next, and the score floor says + * the score is ready rather than what it is. The hero's lead keeps its + * numbers; it is read inside the app. */ function composeLine( - briefingLead: string | null, + overview: { briefingLead: string | null; lead: TodayLead | null }, topSignal: DailyBriefingSignal | null, score: DailyDigestScore | null, t: Translate, ): string { - if (briefingLead) return briefingLead; - if (topSignal?.headline) return topSignal.headline.trim(); - if (score) return t("daily.line.score", { score: score.value }); + const candidates = [ + overview.briefingLead, + topSignal?.headline?.trim() || null, + overview.lead?.source === "signal" ? overview.lead.text : null, + ]; + for (const candidate of candidates) { + if (candidate && !HAS_NUMBER.test(candidate)) return candidate; + } + if (score) return t("daily.line.scoreReady"); return t("daily.line.allClear"); } @@ -1034,7 +1099,6 @@ export function buildDailyDigest( // is the builder's own backstop, so the DTO cannot carry it by accident. const briefing = input.ai.briefing.available ? input.briefing : null; const topSignal = briefing?.signalsOfDay?.[0] ?? null; - const briefingLead = firstSentence(briefing?.paragraph); // The day's most recently LANDED arrival drives both reaction fields. The // sample timestamp may be hours old (sleep synced after waking, an offline @@ -1170,15 +1234,62 @@ export function buildDailyDigest( (item) => !item.itemKey || !input.dismissedItemKeys.has(item.itemKey), ); + const rail = capWithPinned(visible, pinned, MAX_WORTH_A_LOOK); + + const overview = buildTodayOverview( + { + locale: input.locale ?? "en", + modules: input.modules, + reactionLine, + briefing, + scoreValue: input.score?.value ?? null, + medsToday: input.medsToday, + visits: (input.upcomingVisits ?? []).flatMap((visit) => + visit.timeLabel === undefined || + Number.isNaN(new Date(visit.occurredAt).getTime()) + ? [] + : [ + { + id: visit.id, + dayOffset: visit.dayOffset, + timeLabel: visit.timeLabel, + what: + visit.practitionerName ?? + t(encounterKindLabelKey(visit.kind as EncounterKind)), + }, + ], + ), + rail, + restMode: input.restMode ?? null, + sleep: input.sleepLastNight ?? null, + vitals: input.vitals ?? [], + cycle: input.cycle ?? null, + }, + t, + ); + + const score: DailyDigestScore | null = input.score + ? { + ...input.score, + steadyWeeks: input.scoreSteady?.weeks ?? null, + steadyAtLeast: input.scoreSteady?.atLeast ?? false, + } + : null; + return { generatedAt: input.now.toISOString(), phase, sleepPending, - score: input.score, + score, topSignal, - briefingLead, - line: composeLine(briefingLead, topSignal, input.score, t), - worthALook: capWithPinned(visible, pinned, MAX_WORTH_A_LOOK), + briefingLead: overview.briefingLead, + lead: overview.lead, + today: overview.today, + restMode: moduleEnabled(input.modules, "illness") + ? (input.restMode ?? null) + : null, + line: composeLine(overview, topSignal, input.score, t), + worthALook: rail, justIn, reactionLine, ai: input.ai, diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index dcfebbdb3..95c609b3a 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -68,6 +68,35 @@ import { PRIORITY_ITEM_KINDS, type PriorityItemKind, } from "@/lib/daily/priority-item"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; +import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import { + applyDisplayTransform, + getReadingTransform, + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; +import { resolveRestMode } from "@/lib/illness/rest-mode"; +import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict"; +import { addDays, dayDiff } from "@/lib/cycle/day-math"; +import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale"; +import type { + DateFormatPreference, + TimeFormatPreference, +} from "@/lib/format-locale"; +import { + STEADY_READ_DAYS, + steadyRun, + type StoredScoreDay, +} from "@/lib/daily/score-steady"; +import type { + TodayCycle, + TodayRestMode, + TodaySleep, + TodayVital, +} from "@/lib/daily/today-overview"; /** Integration states that mean "your action is needed to keep data flowing". */ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; @@ -206,6 +235,148 @@ interface DailyDigestExtras { * The labels are grouped at the call site, where the locale is resolved. */ sameTime: Omit | null; + /** + * Each banded vital's standing against its personal range, from the + * coincident-deviation engine. Canonical numbers only: the labels depend on + * the reader's units and locale and are rendered per request. + */ + vitals: ExtrasVital[]; + /** The stored daily scores the "steady for" line is read from. */ + scoreDays: StoredScoreDay[]; + /** + * Today's place in the cycle, read only while the cycle module is on. A + * cycle write hard-evicts the cell (`invalidateUserHealthContext`), and a + * module switch does too (`invalidateUserHealthScore`), so a cached + * answer never outlives the rows or the switch it was read under. + */ + cycle: TodayCycleRead | null; +} + +interface ExtrasVital { + type: MeasurementType; + value: number; + low: number; + high: number; + direction: "above" | "below" | "in"; + daysAgo: number; +} + +/** + * The vitals read for the Today overview: every banded vital's latest day + * against its personal range. Fault-isolated, like every extras input; an + * engine that cannot answer (too few banded vitals) leaves the list empty, + * and the overview simply has no vitals line. + */ +async function gatherVitals( + userId: string, + timezone: string, + now: Date, +): Promise { + try { + const profile = await loadBaselineProfile(prisma, userId); + const result = await computeCoincidentDeviation(userId, profile, { + now, + tz: timezone, + }); + if (result.status !== "ok") return []; + return result.value.vitals.map((v) => ({ + type: v.type, + value: v.value, + low: v.low, + high: v.high, + direction: v.direction, + daysAgo: Number.isFinite(v.daysAgo) ? v.daysAgo : Number.MAX_SAFE_INTEGER, + })); + } catch { + return []; + } +} + +/** The stored daily scores for the "steady for" read. Fault-isolated. */ +async function gatherScoreDays( + userId: string, + todayLocalDate: string, +): Promise { + try { + // Calendar arithmetic on the reader's own day key, the space the stored + // rows are keyed in. + const since = addDays(todayLocalDate, -STEADY_READ_DAYS); + const rows = await prisma.healthScoreRecord.findMany({ + where: { userId, dayKey: { gte: since } }, + orderBy: { dayKey: "desc" }, + take: STEADY_READ_DAYS + 1, + select: { + dayKey: true, + composite: true, + band: true, + scoreVersion: true, + composition: true, + }, + }); + return rows.map((row) => ({ + dayKey: row.dayKey, + composite: row.composite, + band: String(row.band), + scoreVersion: row.scoreVersion, + composition: row.composition, + })); + } catch { + return []; + } +} + +/** + * Display decimals per vital. The transform's own `decimals` covers the + * converted units (lb, °F, mmol/L); the rest are whole numbers on every + * device and in every chart that shows them. + */ +function vitalDecimals(type: string, transformDecimals: number): number { + switch (type) { + case "BODY_TEMPERATURE": + case "SKIN_TEMPERATURE": + case "WEIGHT": + return 1; + case "BLOOD_GLUCOSE": + return transformDecimals; + default: + return 0; + } +} + +/** Render one vital's value and range in the reader's units and locale. */ +function toTodayVital( + vital: ExtrasVital, + units: UnitPreferences, + locale: Locale, + t: (key: string, params?: Record) => string, +): TodayVital { + const transform = getReadingTransform(vital.type, units); + const decimals = vitalDecimals(vital.type, transform.decimals); + const number = new Intl.NumberFormat(resolveIntlLocale(locale), { + maximumFractionDigits: decimals, + minimumFractionDigits: decimals, + }); + const shown = (raw: number) => + number.format(applyDisplayTransform(raw, transform)); + const unit = transform.displayUnit; + return { + type: vital.type, + value: vital.value, + low: vital.low, + high: vital.high, + direction: vital.direction, + daysAgo: vital.daysAgo, + valueLabel: t("daily.today.valueWithUnit", { + value: shown(vital.value), + unit, + }), + rangeLabel: t("daily.today.rangeWithUnit", { + low: shown(vital.low), + high: shown(vital.high), + unit, + }), + moduleKey: moduleForMeasurementType(vital.type), + }; } /** @@ -227,6 +398,7 @@ async function loadDailyDigestExtrasCached( timezone: string, todayLocalDate: string, now: Date, + cycleOn: boolean, ): Promise { return cachedSwr( caches.analytics as ServerCache, @@ -236,35 +408,45 @@ async function loadDailyDigestExtrasCached( // revision ran them sequentially). Each is already fault-isolated // internally (a milestone read-hiccup or a tension-read failure leaves // its own field quiet rather than breaking the other). - const [milestone, tensionWindow, sameTime] = await Promise.all([ - gatherFreshMilestone(userId, timezone, now), - loadIntradayPulse(userId, timezone, todayLocalDate) - .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) - .catch(() => null), - // The engine gates itself — it returns its `insufficient` arm while it - // is still learning the usual day, when the account has no intraday - // rows at all (every daily-total source), and before the day's first - // hour has finished. Only the `ok` arm reaches the rail. Passing no - // profile because the engine reads none; fault-isolated like its - // siblings, since the digest is a must-not-fail path. - computeSameTimeBaseline(userId, null, { - type: SAME_TIME_RAIL_TYPE, - now, - }) - .then((d): DailyDigestExtras["sameTime"] => - d.status === "ok" - ? { - type: d.value.type, - band: d.value.band, - asOfHour: d.value.asOfHour, - todayValue: Math.round(d.value.todayValue), - typicalValue: Math.round(d.value.typicalValue), - } - : null, - ) - .catch(() => null), - ]); - return { milestone, tensionWindow, sameTime }; + const [milestone, tensionWindow, sameTime, vitals, scoreDays, cycle] = + await Promise.all([ + gatherFreshMilestone(userId, timezone, now), + loadIntradayPulse(userId, timezone, todayLocalDate) + .then((r) => + r.tension ? { partOfDay: r.tension.partOfDay } : null, + ) + .catch(() => null), + // The engine gates itself — it returns its `insufficient` arm while it + // is still learning the usual day, when the account has no intraday + // rows at all (every daily-total source), and before the day's first + // hour has finished. Only the `ok` arm reaches the rail. Passing no + // profile because the engine reads none; fault-isolated like its + // siblings, since the digest is a must-not-fail path. + computeSameTimeBaseline(userId, null, { + type: SAME_TIME_RAIL_TYPE, + now, + }) + .then((d): DailyDigestExtras["sameTime"] => + d.status === "ok" + ? { + type: d.value.type, + band: d.value.band, + asOfHour: d.value.asOfHour, + todayValue: Math.round(d.value.todayValue), + typicalValue: Math.round(d.value.typicalValue), + } + : null, + ) + .catch(() => null), + gatherVitals(userId, timezone, now), + gatherScoreDays(userId, todayLocalDate), + // The whole cycle history, 90 days of day logs and wrist + // temperature: too much to read on every 120 s poll. + cycleOn + ? readTodayCycle(userId, timezone, now).catch(() => null) + : Promise.resolve(null), + ]); + return { milestone, tensionWindow, sameTime, vitals, scoreDays, cycle }; }, annotate, DIGEST_EXTRAS_CACHE_TTL_MS, @@ -573,14 +755,64 @@ export async function loadDailyDigest( * notification about something the start page no longer mentioned. A visit * that is still PLANNED stays on the rail until its day ends. */ + // The start time on the person's own clock, rendered here in their profile + // timezone and hour-cycle preference. Done on the server so every reader of + // the digest prints the same time for the same visit, whatever zone their + // device is in. + const visitTime = makeFormatters( + locale, + user.timezone, + (user.timeFormat ?? "AUTO") as TimeFormatPreference, + (user.dateFormat ?? "AUTO") as DateFormatPreference, + ).time; const upcomingVisits: DailyDigestUpcomingVisit[] = visitRows.map((row) => ({ id: row.id, kind: row.kind, occurredAt: row.occurredAt.toISOString(), practitionerName: row.practitioner?.name ?? null, dayOffset: calendarDaysUntil(row.occurredAt, now, user.timezone), + timeLabel: visitTime(row.occurredAt), })); + // Rest Mode is read per request; today's cycle day rides the extras cell + // below, which every cycle write hard-evicts. The read is skipped outright + // when its module is off, and fault-isolated, because the digest is a + // must-not-fail path. + const restModeContext = + modules.illness !== false ? await resolveRestMode(user.id, now) : null; + const restMode: TodayRestMode | null = + restModeContext?.active && restModeContext.since + ? { + day: + Math.max( + 0, + dayDiff( + todayLocalDate, + userDayKey(new Date(restModeContext.since), user.timezone), + ), + ) + 1, + } + : null; + + // Last night, when it is in: the snapshot's sleep summary carries per-night + // time asleep in minutes, `latest` being the newest night and `avg30` the + // person's own trailing mean. "In" means the freshest night ended on the + // reader's local today, which is the same test the freshness phase uses. + const sleepSummary = snapshot.tiles.summaries?.SLEEP_DURATION; + const sleepLastNight: TodaySleep | null = + sleepLastSeenDaysAgo === 0 && + sleepSummary?.latest !== null && + sleepSummary?.latest !== undefined && + sleepSummary.latest > 0 + ? { + minutes: sleepSummary.latest, + usualMinutes: + sleepSummary.avg30 !== null && sleepSummary.avg30 > 0 + ? sleepSummary.avg30 + : null, + } + : null; + // S10 — the freshest ECG recording (device verdict + recordedAt only). The // builder decides "new" and gates on the `insights` module. An ECG row only // ever carries an AFib-screening verdict; the shared enum's other members @@ -616,13 +848,23 @@ export async function loadDailyDigest( // a 120 s poll of every open tab doesn't re-run the ~8.5k-row streak + // per-sample tension reads on every request — see its docblock for the // cache/invalidation contract. - const { milestone, tensionWindow, sameTime } = - await loadDailyDigestExtrasCached( - user.id, - user.timezone, - todayLocalDate, - now, - ); + const { + milestone, + tensionWindow, + sameTime, + vitals, + scoreDays, + cycle: cycleRead, + } = await loadDailyDigestExtrasCached( + user.id, + user.timezone, + todayLocalDate, + now, + modules.cycle !== false, + ); + const cycle: TodayCycle | null = cycleRead + ? { dayOfCycle: cycleRead.dayOfCycle, phase: cycleRead.phase } + : null; // Dismiss ledger (P — Today rail dismiss). Only the OBSERVATIONAL kinds // (milestone / ecg_new_recording / tension_window) can ever be dismissed, so @@ -648,6 +890,10 @@ export async function loadDailyDigest( const resolvedLocale: Locale = locale; const { t } = getServerTranslator(resolvedLocale); + const units = resolveUnitPreferences({ + unitPreference: user.unitPreference, + glucoseUnit: user.glucoseUnit, + }); // A Coach-suggested measurement cadence stores its label as a bundle key; // the checkups page and the dashboard card translate it, and so does this @@ -711,6 +957,25 @@ export async function loadDailyDigest( arrivals: arrivalRows .map((row) => toDigestArrival(row, reactionLinesAi.available)) .filter((a): a is DailyDigestArrival => a !== null), + locale: resolvedLocale, + restMode, + sleepLastNight, + vitals: (vitals ?? []).map((v) => + toTodayVital(v, units, resolvedLocale, t), + ), + cycle, + scoreSteady: score + ? steadyRun(scoreDays ?? [], todayLocalDate, { + value: score.value, + band: score.band, + ...(score.scoreVersion !== undefined + ? { scoreVersion: score.scoreVersion } + : {}), + ...(score.composition !== undefined + ? { composition: score.composition } + : {}), + }) + : null, }, t, ); diff --git a/src/lib/daily/score-steady.ts b/src/lib/daily/score-steady.ts new file mode 100644 index 000000000..31d404ad0 --- /dev/null +++ b/src/lib/daily/score-steady.ts @@ -0,0 +1,132 @@ +/** + * "Steady for N weeks": how long the health score has held where it is. + * + * The score moves slowly on purpose (long windows against reference bands), + * so on most days the honest thing to say about it is not a delta but how long + * it has stayed put. That is a real statement only when it comes with a + * duration read off the record, which is what this does: it walks the stored + * daily scores (`HealthScoreRecord`) back from the newest and counts how long + * the number held. + * + * A day belongs to the run while it was computed the same way (same algorithm + * version, same composition), sat in the same band, and stayed within + * {@link STEADY_TOLERANCE} points of the newest value, and the newest stored + * day must match the score on screen the same way. A gap in the record + * longer than {@link STEADY_MAX_GAP_DAYS} ends the run: an absence is not + * evidence of steadiness. + */ +import { addDays, dayDiff } from "@/lib/cycle/day-math"; + +/** Points either side of the newest value that still count as "the same". */ +export const STEADY_TOLERANCE = 2; + +/** Longest stretch without a stored score the run may bridge. */ +export const STEADY_MAX_GAP_DAYS = 3; + +/** Shortest run worth saying out loud. One week is not yet a pattern. */ +export const STEADY_MIN_WEEKS = 2; + +/** How far back the read looks; also the longest run it can report. */ +export const STEADY_READ_DAYS = 120; + +export interface StoredScoreDay { + dayKey: string; + composite: number; + band: string; + scoreVersion: number; + composition: readonly string[]; +} + +function sameComposition(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((id, i) => id === b[i]); +} + +/** The score the ring shows, as the run is compared against it. */ +export interface ShownScore { + value: number; + band: string; + /** Absent on an older snapshot; then only value and band are compared. */ + scoreVersion?: number; + composition?: readonly string[]; +} + +/** How long the score has held. */ +export interface SteadyRun { + /** Whole weeks the run covers, counted over stored days that were read. */ + weeks: number; + /** + * True when the run reaches the start of what was read + * ({@link STEADY_READ_DAYS}): the score has held at least `weeks`, and when + * it started is not known. Said as "at least", never as a start. + */ + atLeast: boolean; +} + +function sameBand(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * How long the score has held, or null when it has not held for at least + * {@link STEADY_MIN_WEEKS}. `rows` may come in any order; `todayKey` is the + * reader's local day, so a record that stopped updating days ago does not + * claim a run that reaches today. The newest stored day must also agree with + * the score on screen in value, band and, where the screen says, algorithm + * version and composition: the ring is computed live, the record nightly, + * and a run that ended overnight is not "steady" next to a number, or a band, + * that has moved. + */ +export function steadyRun( + rows: readonly StoredScoreDay[], + todayKey: string, + /** The score the ring shows; a run that does not end there is not told. */ + shown: ShownScore, +): SteadyRun | null { + if (rows.length === 0) return null; + const sorted = [...rows].sort((a, b) => + a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0, + ); + const newest = sorted[0]; + if (dayDiff(todayKey, newest.dayKey) > STEADY_MAX_GAP_DAYS) return null; + if (Math.abs(newest.composite - shown.value) > STEADY_TOLERANCE) return null; + if (!sameBand(newest.band, shown.band)) return null; + if ( + shown.scoreVersion !== undefined && + newest.scoreVersion !== shown.scoreVersion + ) { + return null; + } + if ( + shown.composition !== undefined && + !sameComposition(newest.composition, shown.composition) + ) { + return null; + } + + let oldest = newest; + let broke = false; + for (const row of sorted.slice(1)) { + if ( + dayDiff(oldest.dayKey, row.dayKey) > STEADY_MAX_GAP_DAYS || + row.scoreVersion !== newest.scoreVersion || + !sameComposition(row.composition, newest.composition) || + !sameBand(row.band, newest.band) || + Math.abs(row.composite - newest.composite) > STEADY_TOLERANCE + ) { + broke = true; + break; + } + oldest = row; + } + + const weeks = Math.floor(dayDiff(todayKey, oldest.dayKey) / 7); + if (weeks < STEADY_MIN_WEEKS) return null; + // Nothing ended the run inside what was read, and what was read ends + // within a bridgeable gap of the read's own start: the run may go back + // further than anything here can tell. + const atLeast = + !broke && + dayDiff(oldest.dayKey, addDays(todayKey, -STEADY_READ_DAYS)) <= + STEADY_MAX_GAP_DAYS; + return { weeks, atLeast }; +} diff --git a/src/lib/daily/today-overview.ts b/src/lib/daily/today-overview.ts new file mode 100644 index 000000000..9f12c6731 --- /dev/null +++ b/src/lib/daily/today-overview.ts @@ -0,0 +1,615 @@ +/** + * The Today overview: the hero's lead line and its short list of facts about + * the day, composed from data the digest already gathered. + * + * Pure and deterministic, like the rest of the digest spine. It never calls a + * model; AI text reaches it only as the already-cached reaction line and + * briefing, and only while their capabilities are available (the caller has + * already nulled them otherwise). Everything a person without any AI provider + * sees here comes from their own readings. + * + * Three rules hold for every line this file writes: + * + * - A fact is a statement. No line asks the person to do anything; the + * worth-a-look rail is the one place on the hero allowed to. + * - A fact about a switched-off module does not exist. Gating happens here, + * on the server, never in a client. + * - Nothing is said twice. When the lead line is built from a fact, that + * fact leaves the list; an appointment already on the rail is not repeated + * under Today. + */ +import type { DailyBriefing } from "@/lib/ai/schema"; +import type { CyclePhase } from "@/lib/cycle/types"; +import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; +import type { Locale } from "@/lib/i18n/config"; +import { pluralKey } from "@/lib/i18n/plural"; +import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; +import type { ModuleKey } from "@/lib/modules/registry"; +import type { PriorityItem } from "@/lib/daily/priority-item"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +/** The most facts the overview ever carries (a wide screen shows them all). */ +export const MAX_TODAY_FACTS = 5; + +/** + * The most facts a narrow screen shows. The list is already in priority + * order, so a phone simply shows its head; the fifth line is never a + * different fact on a phone, only an absent one. + */ +export const MAX_TODAY_FACTS_NARROW = 4; + +export const TODAY_FACT_KINDS = [ + "rest_mode", + "medications", + "appointment", + "sleep", + "vitals", + "cycle", +] as const; + +export type TodayFactKind = (typeof TODAY_FACT_KINDS)[number]; + +/** One line under "Today": a label, a value, and where it leads. */ +export interface TodayFact { + kind: TodayFactKind; + /** Localised label, resolved on the server. */ + label: string; + /** Localised value, resolved on the server. */ + value: string; + /** The page the fact belongs to. */ + href: string; + /** The module that admitted the fact; absent for core data. */ + moduleKey?: ModuleKey; +} + +export const TODAY_LEAD_SOURCES = ["reaction", "briefing", "signal"] as const; + +export type TodayLeadSource = (typeof TODAY_LEAD_SOURCES)[number]; + +/** + * The hero's lead line, resolved. `reaction` and `briefing` are cached model + * text; `signal` is the deterministic sentence built from the day's strongest + * signal, which is what every account without AI reads. + */ +export interface TodayLead { + text: string; + source: TodayLeadSource; +} + +/** + * One vital's standing against its personal band, as the coincident-deviation + * engine computed it, with the two labels the copy needs already rendered in + * the reader's units. + */ +export interface TodayVital { + type: string; + /** Canonical value; only compared, never printed. */ + value: number; + low: number; + high: number; + direction: "above" | "below" | "in"; + /** Whole days between the reading and the reader's local today. */ + daysAgo: number; + /** The reading in the reader's units, unit included ("61 bpm"). */ + valueLabel: string; + /** The band in the reader's units ("52 to 57 bpm"). */ + rangeLabel: string; + /** The module the vital belongs to, or null for core vitals. */ + moduleKey: ModuleKey | null; +} + +/** Last night's sleep, when it is in the record. */ +export interface TodaySleep { + /** Time asleep last night, in minutes. */ + minutes: number; + /** The person's own trailing average, in minutes, when there is one. */ + usualMinutes: number | null; +} + +/** The current illness episode, when Rest Mode is on. */ +export interface TodayRestMode { + /** 1-based day of the episode on the reader's own calendar. */ + day: number; +} + +/** Today's place in the cycle, when the cycle module knows it. */ +export interface TodayCycle { + dayOfCycle: number; + /** Withheld (null) while the engine is still learning the cycle. */ + phase: CyclePhase | null; +} + +/** A booked visit, with its start time rendered for the reader. */ +export interface TodayVisit { + id: string; + /** 0 today, 1 tomorrow, 2 the day after (profile timezone). */ + dayOffset: number; + /** Start time in the reader's zone and clock ("09:30"). */ + timeLabel: string; + /** Practitioner or visit kind, already resolved. */ + what: string; +} + +export interface TodayOverviewInput { + locale: Locale; + modules: Partial>; + /** Reaction line for today's newest arrival, already capability-gated. */ + reactionLine: string | null; + /** The cached briefing, already capability-gated. */ + briefing: DailyBriefing | null; + /** The number inside the ring, so a lead never repeats it. */ + scoreValue: number | null; + medsToday: MedsTodayBlock; + visits: readonly TodayVisit[]; + /** The rail as it will be published, for the appointment de-duplication. */ + rail: readonly PriorityItem[]; + restMode: TodayRestMode | null; + sleep: TodaySleep | null; + vitals: readonly TodayVital[]; + cycle: TodayCycle | null; +} + +export interface TodayOverview { + lead: TodayLead | null; + today: TodayFact[]; + /** The first substantive briefing sentence, for `briefingLead`. */ + briefingLead: string | null; +} + +/** Trim a lead sentence to a lock-screen-friendly length. */ +const MAX_LEAD_LENGTH = 160; + +/** + * How far last night may sit from the person's usual before the night itself + * becomes the day's strongest signal. An hour is a difference anyone notices + * the next day; less than that is ordinary variation and stays a fact. + */ +export const SLEEP_NOTABLE_DIFF_MINUTES = 60; + +/** Within this many minutes of the usual, the night reads as "close to". */ +export const SLEEP_CLOSE_DIFF_MINUTES = 20; + +function moduleOn( + modules: Partial>, + key: ModuleKey, +): boolean { + return modules[key] !== false; +} + +/** Split prose into sentences, keeping each one's own punctuation. */ +function sentences(text: string): string[] { + return ( + text + .trim() + .match(/(?:[^.!?]|[.!?](?=\S))+(?:[.!?]+|$)/g) + ?.map((s) => s.trim()) + .filter((s) => s.length > 0) ?? [] + ); +} + +/** + * Opening words of a salutation, in every shipped language. Matched only at + * the very start of a sentence, and only on a sentence with no number in it: + * "Good morning, your resting heart rate is 52." carries content and stays. + */ +const GREETING_START = + /^(good\s+(morning|afternoon|evening|night|day)|hello|hi|hey|welcome|guten\s+(morgen|tag|abend)|hallo|moin|servus|willkommen|buen[oa]s?\s+(d[ií]as|tardes|noches)|hola|bienvenid[oa]|bonjour|bonsoir|salut|bienvenue|buongiorno|buon\s+pomeriggio|buonasera|ciao|benvenut[oa]|dzie[nń]\s+dobry|dobry\s+wiecz[oó]r|cze[sś][cć]|witaj|witamy|좋은\s*(아침|저녁|오후)|안녕하세요|안녕)(?![\p{L}])/iu; + +/** Short sentence without a number, below which nothing is said. */ +const MIN_CONTENT_WORDS = 4; + +/** + * Whether a sentence is a greeting rather than a statement about the day. + * + * The briefing is model text and often opens with "Good morning." — which, + * taken as the first sentence, became the whole lead and pushed the sentence + * that had something to say out of view. A sentence carrying a number always + * counts as content. Otherwise it is a greeting when it opens with one, or + * when it is too short to say anything (an exclamation, a sign-off). + */ +export function isGreetingOnly(sentence: string): boolean { + const s = sentence.trim(); + if (s.length === 0) return true; + if (/\d/.test(s)) return false; + if (GREETING_START.test(s)) return true; + const words = s.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)); + return words.length < MIN_CONTENT_WORDS; +} + +/** + * Words that name the health score in every shipped language, and the + * "out of 100" forms. A sentence repeats the ring only when it talks about + * the score; a figure that merely shares its digits ("7 h 52 min" next to a + * score of 52) is content. + */ +const SCORE_CONTEXT = + /(? MAX_LEAD_LENGTH + ? `${sentence.slice(0, MAX_LEAD_LENGTH - 1).trimEnd()}…` + : sentence; +} + +/** + * The first sentence of a paragraph that says something: not a greeting, and + * not a restatement of the score inside the ring. Null when no sentence + * qualifies, so the caller falls through to the next source rather than + * showing filler. + */ +export function firstSubstantiveSentence( + paragraph: string | null | undefined, + score: number | null, +): string | null { + if (!paragraph) return null; + for (const sentence of sentences(paragraph)) { + if (isGreetingOnly(sentence)) continue; + if (repeatsScore(sentence, score)) continue; + return clampLead(sentence); + } + return null; +} + +/** + * The model's reaction line, minus any greeting or score sentence. Unlike the + * briefing it may keep more than one sentence: it is written to be read whole. + */ +function cleanReactionLine( + line: string | null, + score: number | null, +): string | null { + if (!line) return null; + const kept = sentences(line).filter( + (s) => !isGreetingOnly(s) && !repeatsScore(s, score), + ); + const text = kept.join(" ").replace(/\s+/g, " ").trim(); + return text.length > 0 ? clampLead(text) : null; +} + +/** Vitals that may speak about today: module on, reading current. */ +function currentVitals(input: TodayOverviewInput): TodayVital[] { + return input.vitals.filter( + (v) => + isCurrentForTodayClaim(v.daysAgo) && + (v.moduleKey === null || moduleOn(input.modules, v.moduleKey)), + ); +} + +/** How far outside its band a vital sits, in band widths. */ +function outsideBy(v: TodayVital): number { + const width = Math.max(v.high - v.low, Number.EPSILON); + if (v.direction === "above") return (v.value - v.high) / width; + if (v.direction === "below") return (v.low - v.value) / width; + return 0; +} + +function vitalLabel(t: Translate, type: string): string { + const key = `daily.today.vital.${type}`; + const label = t(key); + return label === key ? t("daily.today.vital.generic") : label; +} + +function sleepDiff(sleep: TodaySleep): number | null { + if (sleep.usualMinutes === null) return null; + return Math.round(sleep.minutes - sleep.usualMinutes); +} + +function duration(minutes: number, t: Translate): string { + return formatDurationMinutes(Math.round(minutes), t); +} + +interface SignalLead { + text: string; + consumes: TodayFactKind; +} + +/** + * The deterministic lead: one sentence about the day's strongest signal, + * built from numbers the server already holds. In order: + * + * 1. a vital outside its personal range (the furthest out leads); + * 2. a night at least an hour off the person's usual; + * 3. every recent vital inside its range; + * 4. an ordinary night. + * + * Null when none of them holds; the overview then carries the facts alone, + * and never a filler sentence. + */ +function signalLead( + input: TodayOverviewInput, + sleep: TodaySleep | null, + t: Translate, +): SignalLead | null { + const vitals = currentVitals(input); + const outside = vitals + .filter((v) => v.direction !== "in") + .sort((a, b) => outsideBy(b) - outsideBy(a)); + + if (outside.length > 0) { + const v = outside[0]; + const params = { + metric: vitalLabel(t, v.type), + value: v.valueLabel, + range: v.rangeLabel, + }; + const text = + outside.length === 1 + ? t(`daily.lead.vitalOutside.${v.direction}`, params) + : t( + pluralKey( + `daily.lead.vitalsOutside.${v.direction}`, + outside.length - 1, + input.locale, + ), + { ...params, count: outside.length - 1 }, + ); + return { text, consumes: "vitals" }; + } + + const diff = sleep ? sleepDiff(sleep) : null; + if (sleep && diff !== null && Math.abs(diff) >= SLEEP_NOTABLE_DIFF_MINUTES) { + return { + text: t(`daily.lead.sleep.${diff > 0 ? "more" : "less"}`, { + duration: duration(sleep.minutes, t), + diff: duration(Math.abs(diff), t), + }), + consumes: "sleep", + }; + } + + if (vitals.length >= 2) { + return { + text: t( + pluralKey("daily.lead.vitalsInRange", vitals.length, input.locale), + { count: vitals.length }, + ), + consumes: "vitals", + }; + } + if (vitals.length === 1) { + const v = vitals[0]; + return { + text: t("daily.lead.vitalInRange", { + metric: vitalLabel(t, v.type), + value: v.valueLabel, + range: v.rangeLabel, + }), + consumes: "vitals", + }; + } + + if (sleep) { + return { + text: + diff !== null + ? t("daily.lead.sleep.usual", { + duration: duration(sleep.minutes, t), + }) + : t("daily.lead.sleep.plain", { + duration: duration(sleep.minutes, t), + }), + consumes: "sleep", + }; + } + return null; +} + +function restModeFact( + input: TodayOverviewInput, + t: Translate, +): TodayFact | null { + if (!input.restMode || !moduleOn(input.modules, "illness")) return null; + return { + kind: "rest_mode", + label: t("daily.todayFact.restMode.label"), + value: t("daily.todayFact.restMode.value", { day: input.restMode.day }), + href: "/illness", + moduleKey: "illness", + }; +} + +function medicationsFact( + input: TodayOverviewInput, + t: Translate, +): TodayFact | null { + if (!moduleOn(input.modules, "medications")) return null; + const { scheduledToday, takenToday } = input.medsToday; + if (scheduledToday <= 0) return null; + return { + kind: "medications", + label: t("daily.todayFact.medications.label"), + value: t("daily.todayFact.medications.value", { + taken: Math.min(takenToday, scheduledToday), + scheduled: scheduledToday, + }), + href: "/medications", + moduleKey: "medications", + }; +} + +/** + * The next appointment today or tomorrow, and nothing further out. + * + * Skipped when the rail already carries an appointment item: the rail item + * stays (it is the published contract every client reads, and it keeps its + * place under the cap for a visit today), and one card does not name the same + * visit twice. + */ +function appointmentFact( + input: TodayOverviewInput, + t: Translate, +): TodayFact | null { + if (input.rail.some((item) => item.kind === "upcoming_visit")) return null; + const visit = [...input.visits] + .filter((v) => v.dayOffset >= 0 && v.dayOffset <= 1) + .sort((a, b) => a.dayOffset - b.dayOffset)[0]; + if (!visit) return null; + const sameDay = input.visits.filter((v) => v.dayOffset === visit.dayOffset); + const others = sameDay.length - 1; + const what = others > 0 ? `${visit.what} +${others}` : visit.what; + return { + kind: "appointment", + label: t("daily.todayFact.appointment.label"), + value: t( + visit.dayOffset <= 0 + ? "daily.todayFact.appointment.today" + : "daily.todayFact.appointment.tomorrow", + { time: visit.timeLabel, what }, + ), + href: "/checkups", + }; +} + +function sleepFact(sleep: TodaySleep | null, t: Translate): TodayFact | null { + if (!sleep) return null; + const diff = sleepDiff(sleep); + const params = { duration: duration(sleep.minutes, t) }; + const value = + diff === null + ? t("daily.todayFact.sleep.plain", params) + : Math.abs(diff) <= SLEEP_CLOSE_DIFF_MINUTES + ? t("daily.todayFact.sleep.usual", params) + : t(`daily.todayFact.sleep.${diff > 0 ? "more" : "less"}`, { + ...params, + diff: duration(Math.abs(diff), t), + }); + return { + kind: "sleep", + label: t("daily.todayFact.sleep.label"), + value, + href: "/insights/sleep", + moduleKey: "sleep", + }; +} + +function vitalsFact(input: TodayOverviewInput, t: Translate): TodayFact | null { + const vitals = currentVitals(input); + if (vitals.length === 0) return null; + const outside = vitals + .filter((v) => v.direction !== "in") + .sort((a, b) => outsideBy(b) - outsideBy(a)); + let value: string; + if (outside.length === 0) { + value = t( + pluralKey("daily.todayFact.vitals.allIn", vitals.length, input.locale), + { count: vitals.length }, + ); + } else if (outside.length === 1) { + value = t(`daily.todayFact.vitals.oneOut.${outside[0].direction}`, { + metric: vitalLabel(t, outside[0].type), + }); + } else { + value = t("daily.todayFact.vitals.someOut", { + count: outside.length, + total: vitals.length, + }); + } + return { + kind: "vitals", + label: t("daily.todayFact.vitals.label"), + value, + href: "/insights", + }; +} + +function cycleFact(input: TodayOverviewInput, t: Translate): TodayFact | null { + if (!input.cycle || !moduleOn(input.modules, "cycle")) return null; + return { + kind: "cycle", + label: t("daily.todayFact.cycle.label"), + value: input.cycle.phase + ? t("daily.todayFact.cycle.value", { + day: input.cycle.dayOfCycle, + phase: t(`cycle.phase.${input.cycle.phase}`), + }) + : t("daily.todayFact.cycle.dayOnly", { day: input.cycle.dayOfCycle }), + href: "/cycle", + moduleKey: "cycle", + }; +} + +/** + * Compose the lead line and the Today facts. + * + * Lead precedence, warmest first: the reaction line to something that landed + * today, then the first briefing sentence with content, then the briefing's + * top-signal headline, then the deterministic sentence from the strongest + * signal. The first two are model text and only reach this function while + * their capabilities are available; the deterministic sentence is what makes + * the card whole without them. + * + * Facts follow a fixed priority (Rest Mode, medications, an appointment today + * or tomorrow, last night, vitals, cycle) and are capped at + * {@link MAX_TODAY_FACTS}. The list is never padded: a quiet account gets as + * many lines as it has facts, and none at all is a valid answer. + */ +export function buildTodayOverview( + input: TodayOverviewInput, + t: Translate, +): TodayOverview { + const sleep = moduleOn(input.modules, "sleep") ? input.sleep : null; + + const briefingLead = firstSubstantiveSentence( + input.briefing?.paragraph, + input.scoreValue, + ); + const reaction = cleanReactionLine(input.reactionLine, input.scoreValue); + const headline = input.briefing?.signalsOfDay?.[0]?.headline?.trim() || null; + + let lead: TodayLead | null = null; + let consumed: TodayFactKind | null = null; + if (reaction) { + lead = { text: reaction, source: "reaction" }; + } else if (briefingLead) { + lead = { text: briefingLead, source: "briefing" }; + } else if (headline && !repeatsScore(headline, input.scoreValue)) { + lead = { text: clampLead(headline), source: "briefing" }; + } else { + const signal = signalLead(input, sleep, t); + if (signal) { + lead = { text: signal.text, source: "signal" }; + consumed = signal.consumes; + } + } + + const facts = [ + restModeFact(input, t), + medicationsFact(input, t), + appointmentFact(input, t), + sleepFact(sleep, t), + vitalsFact(input, t), + cycleFact(input, t), + ].filter( + (fact): fact is TodayFact => fact !== null && fact.kind !== consumed, + ); + + return { + lead, + today: facts.slice(0, MAX_TODAY_FACTS), + briefingLead, + }; +} diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index 6beccb201..35f2864a0 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -181,6 +181,34 @@ describe("buildMedsTodayBlock — projection contract", () => { }); }); +describe("buildMedsTodayBlock — the 25-hour day", () => { + it("covers the last local hour on the day the clocks go back", async () => { + // 2026-10-25 in Berlin runs 25 hours: CEST midnight is 22:00Z the + // evening before, the next CET midnight 23:00Z. A dose at 23:30 local + // (22:30Z) lies inside the day and must be counted. + const dstNow = new Date("2026-10-25T20:00:00.000Z"); + await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, dstNow); + + const arg = projectTodayIntakesAndRecompute.mock.calls[0][0] as { + todayStart: Date; + todayEnd: Date; + }; + expect(arg.todayStart.toISOString()).toBe("2026-10-24T22:00:00.000Z"); + expect(arg.todayEnd.toISOString()).toBe("2026-10-25T23:00:00.000Z"); + + const where = capturedTodayWhere as { + scheduledFor: { gte: Date; lt: Date }; + }; + expect(where.scheduledFor.lt.toISOString()).toBe( + "2026-10-25T23:00:00.000Z", + ); + expect( + where.scheduledFor.lt.getTime() > + new Date("2026-10-25T22:30:00.000Z").getTime(), + ).toBe(true); + }); +}); + describe("buildMedsTodayBlock — tally", () => { it("counts scheduled / taken / skipped with the summary-tile semantics", async () => { todayRows = [ diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 7cefb726d..017008912 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -104,10 +104,11 @@ export async function buildMedsTodayBlock( ): Promise { const { start: todayStart, end: todayEnd } = getUserTodayBounds(now, userTz); // The projector + tally read use an exclusive upper bound (`lt`), - // matching the summary route's window convention. - const todayEndExclusive = new Date( - todayStart.getTime() + 24 * 60 * 60 * 1000, - ); + // matching the summary route's window convention. `todayEnd` is the + // inclusive last millisecond of the real local day, so +1 ms is the next + // day's start: 23 or 25 hours on a DST day, where a fixed 24 h missed the + // last hour of the 25-hour day. + const todayEndExclusive = new Date(todayEnd.getTime() + 1); // Projection FIRST so the tally read below sees the freshly minted // pending rows. Idempotent — `skipDuplicates` + the diff --git a/src/lib/documents/links.ts b/src/lib/documents/links.ts index 70e028ed4..92d26419c 100644 --- a/src/lib/documents/links.ts +++ b/src/lib/documents/links.ts @@ -7,10 +7,13 @@ */ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { decryptField } from "@/lib/encounters/dto"; +import { MAX_PROCEDURES } from "@/lib/encounters/procedures"; import { listTargetsBySource, replaceTargets } from "@/lib/links"; import type { DocumentConditionLinkDto, DocumentEncounterLinkDto, + DocumentLinkedProcedureDto, DocumentVaccinationLinkDto, } from "@/lib/validations/inbound-documents"; import { resolveCatalogEntry } from "@/lib/vaccinations/vaccine-catalog"; @@ -77,6 +80,50 @@ export async function loadDocumentEncounterLinks( return map; } +/** + * The record's procedures and surgeries that hold at least one LIVE document + * link, newest first — the vault filter bar's procedure choices. + * + * Every live PROCEDURE visit counts whatever its status: a pre-op letter filed + * against a booked operation belongs to it as much as the discharge letter + * belongs to one that happened. Tombstoned visits and tombstoned documents + * drop out, the same honest default `loadDocumentEncounterLinks` uses. The + * reason and body site are ciphertext at rest and decrypt through the visit + * DTO's fail-soft path, so one row with a key gap reads as unnamed rather than + * failing the usage read. Only the label's columns are selected. Bounded like the procedure history itself. + */ +export async function loadLinkedProcedures( + userId: string, +): Promise { + const rows = await prisma.encounter.findMany({ + where: { + userId, + deletedAt: null, + kind: "PROCEDURE", + documentLinks: { some: { userId, document: { deletedAt: null } } }, + }, + orderBy: [{ occurredAt: "desc" }, { id: "asc" }], + take: MAX_PROCEDURES, + // Only what the label needs: no outcome, no practitioner contact fields. + select: { + id: true, + occurredAt: true, + reasonEncrypted: true, + bodySiteEncrypted: true, + laterality: true, + practitioner: { select: { name: true } }, + }, + }); + return rows.map((row) => ({ + encounterId: row.id, + occurredAt: row.occurredAt.toISOString(), + reason: decryptField(row.reasonEncrypted, "reason"), + bodySite: decryptField(row.bodySiteEncrypted, "bodySite"), + laterality: row.laterality, + practitionerName: row.practitioner?.name ?? null, + })); +} + /** * Narrow a client-sent visit-id list to the caller's LIVE visits. * diff --git a/src/lib/encounters/dto.ts b/src/lib/encounters/dto.ts index d2fed17d2..f12c18d0d 100644 --- a/src/lib/encounters/dto.ts +++ b/src/lib/encounters/dto.ts @@ -97,7 +97,7 @@ export interface EncounterDTO { updatedAt: string; } -function decryptField( +export function decryptField( value: Uint8Array | null, field: "reason" | "outcome" | "bodySite", ): string | null { diff --git a/src/lib/insights/__tests__/coach-conversation-groups.test.ts b/src/lib/insights/__tests__/coach-conversation-groups.test.ts new file mode 100644 index 000000000..5b5116e9c --- /dev/null +++ b/src/lib/insights/__tests__/coach-conversation-groups.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { groupConversationsByRecency } from "../coach-conversation-groups"; + +// The suite runs with TZ=UTC, so local day boundaries are UTC midnights. +const NOW = new Date("2026-10-04T15:00:00.000Z"); + +function c(id: string, updatedAt: string) { + return { id, updatedAt }; +} + +describe("groupConversationsByRecency", () => { + it("buckets by local day: today, yesterday, the six days before, earlier", () => { + const groups = groupConversationsByRecency( + [ + c("a", "2026-10-04T00:00:00.000Z"), // today, at midnight + c("b", "2026-10-03T23:59:59.000Z"), // yesterday, last second + c("c", "2026-10-03T00:00:00.000Z"), // yesterday, first second + c("d", "2026-09-28T00:00:00.000Z"), // six days before today + c("e", "2026-09-27T23:59:59.000Z"), // one second older: earlier + ], + NOW, + ); + expect(groups.map((g) => [g.id, g.conversations.map((x) => x.id)])).toEqual( + [ + ["today", ["a"]], + ["yesterday", ["b", "c"]], + ["thisWeek", ["d"]], + ["earlier", ["e"]], + ], + ); + }); + + it("drops empty groups and keeps the caller's order inside a group", () => { + const groups = groupConversationsByRecency( + [c("x", "2026-01-02T00:00:00.000Z"), c("y", "2026-03-02T00:00:00.000Z")], + NOW, + ); + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe("earlier"); + expect(groups[0].labelKey).toBe("insights.coach.history.groupEarlier"); + expect(groups[0].conversations.map((x) => x.id)).toEqual(["x", "y"]); + }); + + it("returns nothing for an empty list", () => { + expect(groupConversationsByRecency([], NOW)).toEqual([]); + }); +}); diff --git a/src/lib/insights/coach-conversation-groups.ts b/src/lib/insights/coach-conversation-groups.ts new file mode 100644 index 000000000..44d24ac6d --- /dev/null +++ b/src/lib/insights/coach-conversation-groups.ts @@ -0,0 +1,58 @@ +/** + * Bucket Coach conversations by how recently they were last touched: Today, + * Yesterday, This week (the six days before yesterday), Earlier. + * + * Shared by the Coach page's conversations panel and the standalone + * conversation list, so both group a thread the same way. Comparisons run + * against LOCAL day boundaries, so "Today" follows the person's own midnight + * rather than UTC's. Empty groups are dropped; order inside a group is the + * caller's (the server already returns `updatedAt desc`). + */ +export type CoachRecencyGroupId = + "today" | "yesterday" | "thisWeek" | "earlier"; + +export interface CoachRecencyGroup { + id: CoachRecencyGroupId; + labelKey: string; + conversations: T[]; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +const GROUPS: ReadonlyArray<{ id: CoachRecencyGroupId; labelKey: string }> = [ + { id: "today", labelKey: "insights.coach.history.groupToday" }, + { id: "yesterday", labelKey: "insights.coach.history.groupYesterday" }, + { id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" }, + { id: "earlier", labelKey: "insights.coach.history.groupEarlier" }, +]; + +export function groupConversationsByRecency( + conversations: readonly T[], + now: Date = new Date(), +): CoachRecencyGroup[] { + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const startOfYesterday = startOfToday - DAY_MS; + const startOfWeek = startOfToday - 6 * DAY_MS; + + const buckets: Record = { + today: [], + yesterday: [], + thisWeek: [], + earlier: [], + }; + for (const conversation of conversations) { + const updated = new Date(conversation.updatedAt).getTime(); + if (updated >= startOfToday) buckets.today.push(conversation); + else if (updated >= startOfYesterday) buckets.yesterday.push(conversation); + else if (updated >= startOfWeek) buckets.thisWeek.push(conversation); + else buckets.earlier.push(conversation); + } + + return GROUPS.map((g) => ({ ...g, conversations: buckets[g.id] })).filter( + (g) => g.conversations.length > 0, + ); +} diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index 27a5c3962..4d3df5f54 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -49,6 +49,12 @@ const ADHERENCE_WINDOW_DAYS = 14; /** Window for the target-vital before/after read (split in half). */ const VITAL_WINDOW_DAYS = 28; +/** The furthest back the storyline reads: the longer of its two windows. */ +export const ADHERENCE_STORYLINE_HORIZON_DAYS = Math.max( + ADHERENCE_WINDOW_DAYS, + VITAL_WINDOW_DAYS, +); + /** Adherence must clear this many days of scheduled doses to be trusted. */ const MIN_ADHERENCE_DAYS = 7; /** Each side of the vital before/after read needs this many days. */ diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index 810526388..c36d648ef 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -177,7 +177,7 @@ const CHANGEPOINT_METRICS: readonly MeasurementType[] = [ ]; /** Window the reader looks back over (days). */ -const CHANGEPOINT_WINDOW_DAYS = 90; +export const CHANGEPOINT_WINDOW_DAYS = 90; /** At most this many changepoints in one snapshot (strongest first). */ const MAX_CHANGEPOINTS = 2; diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 152c4401f..83f75301e 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -231,17 +231,25 @@ export async function computeCoincidentDeviation( const todayKey = userDayKey(now, tz); // Fan the baseline engine across the supported vitals. Each baseline read - // shares the one coverage probe (no per-vital re-probe). - for (const type of VITALS_BASELINE_TYPES) { - const baseline = await computeVitalsBaseline(userId, profile, { - type, - windowDays, - now, - coverage, - }); + // shares the one coverage probe (no per-vital re-probe). The types are + // independent, so their reads run together; the results are folded in the + // registry's order, so the output does not depend on which finished first. + const perType = await Promise.all( + VITALS_BASELINE_TYPES.map(async (type) => { + const baseline = await computeVitalsBaseline(userId, profile, { + type, + windowDays, + now, + coverage, + }); + if (baseline.status !== "ok") return { type, baseline, latest: null }; + const latest = await readLatestDayMean(userId, type, windowDays, now, tz); + return { type, baseline, latest }; + }), + ); + for (const { type, baseline, latest } of perType) { if (baseline.status !== "ok") continue; if (baseline.provenance.source === "DAY") anyDaySource = true; - const latest = await readLatestDayMean(userId, type, windowDays, now, tz); if (!latest) continue; if (latest.day > latestDay) latestDay = latest.day; if (baseline.coverage.historyDays > maxHistoryDays) { diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 9d5026d18..4397fc6ce 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -43,5 +43,8 @@ export { computeCoincidentDeviation } from "./coincident-deviation"; export type { WellnessScoreValue } from "./wellness-scores"; -export { computeTrajectory } from "./trajectory"; +export { + computeTrajectory, + TRAJECTORY_DEFAULT_WINDOW_DAYS, +} from "./trajectory"; export type { TrajectoryValue } from "./trajectory"; diff --git a/src/lib/insights/derived/signal-trust.ts b/src/lib/insights/derived/signal-trust.ts index 995ee2987..be2a9dfa2 100644 --- a/src/lib/insights/derived/signal-trust.ts +++ b/src/lib/insights/derived/signal-trust.ts @@ -23,7 +23,7 @@ import { } from "@/lib/insights/derived/recovery-resolve"; /** How far back the divergence read looks for a comparable two-source night. */ -const SIGNAL_TRUST_WINDOW_DAYS = 10; +export const SIGNAL_TRUST_WINDOW_DAYS = 10; /** The snapshot block shape — small, descriptive, never a number the model recites. */ export interface SignalTrustBlock { diff --git a/src/lib/insights/derived/trajectory.ts b/src/lib/insights/derived/trajectory.ts index 5707ca590..259d3c952 100644 --- a/src/lib/insights/derived/trajectory.ts +++ b/src/lib/insights/derived/trajectory.ts @@ -56,6 +56,8 @@ import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format"; /** Trailing window the fit reads (days). Matches the baseline engine. */ const DEFAULT_WINDOW_DAYS = 30; +/** The window `computeTrajectory` reads when called without one. */ +export const TRAJECTORY_DEFAULT_WINDOW_DAYS = DEFAULT_WINDOW_DAYS; /** Default short projection horizon (days). Deliberately conservative. */ const DEFAULT_HORIZON_DAYS = 14; /** Hard ceiling on the horizon — the literature warns off long horizons. */ diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 9fdb1f270..f5f8ef356 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -59,9 +59,16 @@ interface AllTimeExtremes { export async function readAllTimeExtremes( userId: string, types: readonly MeasurementType[], + /** + * The Coach's lookback floor. Given, "all time" means all the history the + * Coach may read, and nothing older enters the figures. + */ + floor: Date | null = null, ): Promise> { const out = new Map(); if (types.length === 0) return out; + const since = + floor === null ? Prisma.empty : Prisma.sql`AND m."measured_at" >= ${floor}`; const priorityJson = await loadUserSourcePriority(userId); const rank = Prisma.raw( buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'), @@ -91,6 +98,7 @@ export async function readAllTimeExtremes( WHERE m."user_id" = ${userId} AND m."deleted_at" IS NULL AND m."type" IN (${typeList}) + ${since} AND ${Prisma.raw(isHourlyMeanTypeSql('m."type"'))} ), weighted AS ( @@ -116,6 +124,7 @@ export async function readAllTimeExtremes( WHERE m."user_id" = ${userId} AND m."deleted_at" IS NULL AND m."type" IN (${typeList}) + ${since} GROUP BY m."type", m."source", 3 ), canon AS ( diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 769191201..08e790466 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -147,10 +147,14 @@ export interface MoodDimensionFeature { */ async function buildMoodDimensionFeatures( userId: string, + floor: Date | null, ): Promise { const tz = await resolveUserTimezone(userId); const { t } = getServerTranslator("en"); - const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const since = laterDate( + new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), + floor, + ); const rows = await prisma.moodEntry.findMany({ where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, orderBy: { moodLoggedAt: "desc" }, @@ -227,7 +231,15 @@ async function buildMoodDimensionFeatures( * Bounded: the same 90-day window the dimensions use, and the comparison's own * cap on how many rows survive. */ -async function buildMoodContextBlock(userId: string): Promise<{ +/** The later of `date` and an optional floor. */ +function laterDate(date: Date, floor: Date | null): Date { + return floor !== null && floor.getTime() > date.getTime() ? floor : date; +} + +async function buildMoodContextBlock( + userId: string, + floor: Date | null, +): Promise<{ context?: { note: string; comparisons: Array<{ @@ -240,7 +252,10 @@ async function buildMoodContextBlock(userId: string): Promise<{ }>; }; }> { - const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const since = laterDate( + new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), + floor, + ); const rows = await prisma.moodEntry.findMany({ where: { userId, @@ -318,11 +333,14 @@ async function buildMoodContextBlock(userId: string): Promise<{ * block-level `asOf`. Absent entirely when nobody has answered a dimension, * so an account that only ever taps a face carries neither key. */ -async function buildMoodDimensionBlock(userId: string): Promise<{ +async function buildMoodDimensionBlock( + userId: string, + floor: Date | null, +): Promise<{ dimensions?: MoodDimensionFeature[]; dimensionsAsOfNote?: string; }> { - const dimensions = await buildMoodDimensionFeatures(userId); + const dimensions = await buildMoodDimensionFeatures(userId, floor); if (!dimensions) return {}; return { dimensions, @@ -966,7 +984,11 @@ const INTEGRATION_BLOCK_MIN_WINDOW_DAYS = 180; * `extractFeatures` so a caller that needs only this block (the single-metric * Coach read) runs exactly the same reads without the rest of the function. */ -async function readMoodFeature(userId: string): Promise<{ +async function readMoodFeature( + userId: string, + /** The Coach's lookback floor, or null: see `extractFeatures`. */ + historyFloor: Date | null, +): Promise<{ feature: AggregatedFeatures["mood"]; dailyPoints: Array<{ measuredAt: Date; value: number; count: number }>; }> { @@ -995,7 +1017,10 @@ async function readMoodFeature(userId: string): Promise<{ void ensureUserMoodRollupsFresh(userId); // Five-year window mirrors the mood-rollup writer default; covers // every realistic user history span without an unbounded scan. - const moodSince = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000); + const moodSince = laterDate( + new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000), + historyFloor, + ); const moodRollupDayRows = await readMoodDayRollups(userId, moodSince); type MoodDayPoint = { @@ -1017,7 +1042,11 @@ async function readMoodFeature(userId: string): Promise<{ // Latest score = newest individual entry (one bounded row). const latestEntry = await prisma.moodEntry.findFirst({ // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null }, + where: { + userId, + deletedAt: null, + ...(historyFloor ? { moodLoggedAt: { gte: historyFloor } } : {}), + }, orderBy: { moodLoggedAt: "desc" }, select: { score: true }, }); @@ -1025,7 +1054,10 @@ async function readMoodFeature(userId: string): Promise<{ } else { // Coverage-fallback. Bounded 1-year walk (instead of the legacy // unbounded `findMany`) so even a cache miss is capped. - const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); + const oneYearAgo = laterDate( + new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), + historyFloor, + ); const moodEntriesRaw = await prisma.moodEntry.findMany({ // v1.7.0 sync — exclude tombstoned rows. where: { userId, deletedAt: null, moodLoggedAt: { gte: oneYearAgo } }, @@ -1111,9 +1143,9 @@ async function readMoodFeature(userId: string): Promise<{ latest: moodLatestScore, trend30, totalEntries: moodTotalEntries, - ...(await buildMoodDimensionBlock(userId)), - ...(await buildMoodContextBlock(userId)), - ...(await buildMoodPrognosisBlock(userId)), + ...(await buildMoodDimensionBlock(userId, historyFloor)), + ...(await buildMoodContextBlock(userId, historyFloor)), + ...(historyFloor ? {} : await buildMoodPrognosisBlock(userId)), coverage: { count: moodTotalEntries, spanDays, @@ -1168,9 +1200,17 @@ export async function extractFeatures( * by the single-metric Coach read, which reads one block and nothing else. */ only?: ReadonlySet; + /** + * The Coach's lookback floor. Given, nothing older than it is read: the + * `allTime*` figures cover the history from the floor on, the mood + * history and its sub-blocks start at it, and the mood forecast, a model + * fitted on the whole history, is left out. + */ + historyFloor?: Date; } = {}, ): Promise { const only = options.only; + const historyFloor = options.historyFloor ?? null; const wants = (block: ScopedFeatureBlock) => !only || only.has(block); const allTimeTypes = (["weight", "bloodPressure", "pulse"] as const).flatMap( (block) => (wants(block) ? SCOPED_BLOCK_TYPES[block] : []), @@ -1250,6 +1290,7 @@ export async function extractFeatures( only ? allTimeTypes : ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], + historyFloor, ) : null; @@ -1491,7 +1532,7 @@ export async function extractFeatures( if (only) { if (only.has("mood")) { - const moodRead = await readMoodFeature(userId); + const moodRead = await readMoodFeature(userId, historyFloor); if (moodRead.feature) features.mood = moodRead.feature; } return features; @@ -1600,7 +1641,7 @@ export async function extractFeatures( } // Mood — see `readMoodFeature`. - const moodRead = await readMoodFeature(userId); + const moodRead = await readMoodFeature(userId, historyFloor); const moodDailyPoints = moodRead.dailyPoints; if (moodRead.feature) features.mood = moodRead.feature; @@ -1769,7 +1810,10 @@ export async function extractFeatures( // findMany loop. Same shape as the v1.3.0 fix to /api/insights/comprehensive // (the previous N+1 the v3 audit closed). 90 days is the longest window // calculateCompliance uses below, so we don't need the full intake history. - const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000); + const ninetyDaysAgo = laterDate( + new Date(Date.now() - 90 * 86_400_000), + historyFloor, + ); const allEvents = await prisma.medicationIntakeEvent.findMany({ where: { userId, diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 8a333944a..7d5f04749 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -131,6 +131,11 @@ export interface McpToolDefinition { * server-authoritative snapshot builder scoped to the requested domain, and * returns the grounded `{ present, reason?, data?, grounding? }` result. It * never throws and never widens scope. + * + * No `reach` is passed, here or at the other MCP call sites: the person's + * Coach lookback limit (`coachPrefs.defaultWindow`) governs what the Coach + * reads, while an MCP client reads with its own window arguments under its + * own token grant, as it did before the limit existed. */ async function runCoachTool( ctx: McpAuthContext, diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 64a777261..86e057173 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -13,6 +13,12 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { ARRIVAL_KINDS } from "@/lib/arrivals/types"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { + MAX_TODAY_FACTS, + MAX_TODAY_FACTS_NARROW, + TODAY_FACT_KINDS, + TODAY_LEAD_SOURCES, +} from "@/lib/daily/today-overview"; import { dismissPriorityItemSchema } from "@/lib/validations/daily"; import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; import { healthScoreBasis } from "./insights/schemas"; @@ -57,7 +63,24 @@ const priorityItemSchema = z "One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard.", }); -const dailyDigestResponse = z +const todayFactSchema = z + .object({ + kind: z.enum([...TODAY_FACT_KINDS]).describe("Closed fact kind."), + label: z.string().describe("Localised label (resolved server-side)."), + value: z.string().describe("Localised value (resolved server-side)."), + href: z.string().describe("The page the fact belongs to."), + moduleKey: z + .string() + .optional() + .describe("The module that admitted the fact; absent for core data."), + }) + .meta({ + id: "DailyTodayFact", + description: + "One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is the only place that does.", + }); + +export const dailyDigestResponse = z .object({ generatedAt: z.string().describe("ISO-8601 instant the digest was read."), phase: z @@ -103,6 +126,21 @@ const dailyDigestResponse = z .describe( "What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three, so this is what distinguishes a narrow score from a broad one. Optional so older cached digests without the field stay valid.", ), + steadyWeeks: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + "Whole weeks the score has held where it is (same band, within two points, computed the same way), read off the stored daily scores. Null when it has not held for two weeks, when the stored record does not reach today, or when the score on screen differs from the newest stored day in value, band, algorithm version or composition. Shown in place of a delta, never beside one.", + ), + steadyAtLeast: z + .boolean() + .optional() + .describe( + 'True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower bound, said as "at least", and the run\'s start is unknown. Absent on older cached digests.', + ), }) .nullable() .describe( @@ -124,12 +162,43 @@ const dailyDigestResponse = z .string() .nullable() .describe( - "First sentence of the cached briefing paragraph. Null while the `briefing` capability is unavailable; `line` then falls to its deterministic floor.", + "The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence qualifies; `line` then falls to its deterministic floor.", + ), + lead: z + .object({ + text: z.string(), + source: z + .enum([...TODAY_LEAD_SOURCES]) + .describe( + "`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's strongest signal.", + ), + }) + .nullable() + .describe( + "The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40.", + ), + today: z + .array(todayFactSchema) + .max(MAX_TODAY_FACTS) + .describe( + `The Today overview: up to ${MAX_TODAY_FACTS} statements about the day in priority order (Rest Mode, medications taken today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow surface shows the first ${MAX_TODAY_FACTS_NARROW}. Additive since v1.40.`, + ), + restMode: z + .object({ + day: z + .number() + .int() + .positive() + .describe("1-based day of the episode on the reader's calendar."), + }) + .nullable() + .describe( + "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive since v1.40.", ), line: z .string() .describe( - "Push / lock-screen line: cached-AI lead with a deterministic floor.", + "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or score), since it is shown on a locked phone; the in-app `lead` keeps its figures.", ), worthALook: z .array(priorityItemSchema) diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 4d413fd4f..1c585fa2d 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -379,7 +379,8 @@ export const inboundDocumentPaths: NonNullable = { in: "query", required: false, schema: { type: "string" }, - description: "Only documents linked to this visit.", + description: + "Only documents linked to this visit. The procedure filter sends a `linkedProcedures` id from the usage read; an id that is not one of the caller's visits matches nothing.", }, { name: "year", @@ -665,7 +666,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Storage usage + effective limits", description: - "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips). Read this before offering an upload.", + "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips), and `linkedProcedures` (visits of kind PROCEDURE carrying at least one live document link, newest first — the procedure-filter choices; pass one's `encounterId` to the list's `encounterId` filter). `linkedProcedures` is empty when a sharing grant does not cover the visits section. Read this before offering an upload.", responses: { ...recordRefusal(), "200": { @@ -685,6 +686,18 @@ export const inboundDocumentPaths: NonNullable = { name: z.string(), }), ), + linkedProcedures: z.array( + z.object({ + encounterId: z.string(), + occurredAt: z.iso.datetime({ offset: true }), + reason: z.string().nullable(), + bodySite: z.string().nullable(), + laterality: z + .enum(["LEFT", "RIGHT", "BOTH"]) + .nullable(), + practitionerName: z.string().nullable(), + }), + ), assistAvailable: z .boolean() .describe( diff --git a/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts index cb3f8c6bc..801a347f4 100644 --- a/src/lib/validations/__tests__/coach-prefs.test.ts +++ b/src/lib/validations/__tests__/coach-prefs.test.ts @@ -154,13 +154,30 @@ describe("parseCoachPrefs", () => { expect(out.dataClusters).toEqual([]); }); - it("falls back to defaults when dataClusters carries an unknown cluster", () => { - // Shape drift — an unknown cluster string fails the enum, the whole - // parse fails, and parseCoachPrefs returns the legacy defaults - // (dataClusters undefined → legacy cluster expansion). + it("keeps the known clusters when dataClusters carries an unknown one", () => { + // Shape drift: a cluster a newer version added. Falling back to the + // legacy default set would widen what the Coach reads past what the + // person chose; dropping only the unknown entry narrows it. const out = parseCoachPrefs({ dataClusters: ["cardio", "astrology"] }); - expect(out).toEqual(DEFAULT_COACH_PREFS); - expect(out.dataClusters).toBeUndefined(); + expect(out.dataClusters).toEqual(["cardio"]); + }); + + it("lets an invalid field fall back alone and keeps the exclusions", () => { + // A window value this version does not know (written by a newer one, + // read after a rollback) must not take the metric exclusions with it. + const out = parseCoachPrefs({ + tone: "neutral", + defaultWindow: "lastDecade", + excludeMetrics: ["mood", "weight"], + }); + expect(out.defaultWindow).toBe("allTime"); + expect(out.excludeMetrics).toEqual(["mood", "weight"]); + expect(out.tone).toBe("neutral"); + }); + + it("keeps the known exclusions when the list carries an unknown metric", () => { + const out = parseCoachPrefs({ excludeMetrics: ["mood", "aura"] }); + expect(out.excludeMetrics).toEqual(["mood"]); }); it("DEFAULT_COACH_CLUSTERS preserves the legacy five domains' clusters", () => { diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index 9c09828b4..b1ea83a90 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -8,11 +8,9 @@ * `excludeMetrics` BEFORE landing in the system prompt so the model * never sees data the user opted out of. * - * v1.4.25 W5 — extended with `defaultWindow`. The picker lives in the - * settings sheet; the drawer header carries a per-conversation override - * pill so a single chat can narrow the window without flipping the - * global default. The chat route folds the saved preference into the - * snapshot scope when the client didn't supply a window override. + * v1.4.25 W5 — extended with `defaultWindow`, since then made the + * Coach's lookback limit: a per-conversation window can narrow it, never + * widen it (`src/lib/ai/coach/history-reach.ts`). */ import { z } from "zod/v4"; @@ -67,20 +65,20 @@ export const coachExcludeMetricEnum = z.enum([ export type CoachExcludeMetric = z.infer; /** - * v1.4.25 W5 — default analysis window the Coach uses when the client - * doesn't supply a per-conversation override. Mirrors - * `CoachScopeWindow` (`src/lib/ai/coach/types.ts`) so the chat route - * can fold the preference into `scope.window` without a translation - * layer. The default stays `allTime` to preserve the v1.4.24 behaviour - * — every legacy row reads as "no opinion" until the user explicitly - * picks a tighter default. + * How far back the Coach may look. This is a LIMIT, enforced + * by every Coach read (`src/lib/ai/coach/history-reach.ts`): the snapshot, + * every retrieval tool, the availability probe and the fixed-window blocks. + * Before, it only seeded the snapshot window and each tool's default, and a + * tool could read further back on its own. Values mirror `CoachScopeWindow` + * (`src/lib/ai/coach/types.ts`) so the chat route folds it into the scope + * without a translation layer. `lastYear` came with the limit. The default + * stays `allTime`, which is no limit, so a legacy row keeps its behaviour. */ -export const coachDefaultWindowEnum = z.enum([ - "last7days", - "last30days", - "last90days", - "allTime", -]); +export const coachDefaultWindowEnum = z + .enum(["last7days", "last30days", "last90days", "lastYear", "allTime"]) + .describe( + "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit.", + ); export type CoachDefaultWindow = z.infer; /** @@ -218,13 +216,44 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = { /** * Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`, - * falling back to defaults when the row is null OR the persisted shape - * has drifted (a forward-compat field rename, an admin-side hand-edit, - * etc.). Keeps the call sites at `src/lib/ai/coach/snapshot.ts` and - * `src/lib/ai/coach/system-prompt.ts` free of the null/parse plumbing. + * falling back to defaults when the row is null. Keeps the call sites at + * `src/lib/ai/coach/snapshot.ts` and `src/lib/ai/coach/system-prompt.ts` + * free of the null/parse plumbing. + * + * A drifted shape (a value a newer version wrote and this one reads after a + * rollback, a hand-edit) is salvaged field by field: an invalid field falls + * back to its own default and every valid one stays. Parsing the blob as a + * whole used to drop everything on one bad field, metric exclusions + * included, which turned an unknown lookback value into a privacy loss. The + * two lists keep their known entries, so an unknown metric or cluster + * narrows what the Coach reads rather than widening it. */ export function parseCoachPrefs(raw: unknown): CoachPrefs { if (raw == null) return DEFAULT_COACH_PREFS; - const parsed = coachPrefsSchema.safeParse(raw); - return parsed.success ? parsed.data : DEFAULT_COACH_PREFS; + const whole = coachPrefsSchema.safeParse(raw); + if (whole.success) return whole.data; + if (typeof raw !== "object" || Array.isArray(raw)) { + return DEFAULT_COACH_PREFS; + } + const source = raw as Record; + const salvaged: Record = {}; + for (const [key, field] of Object.entries(coachPrefsSchema.shape)) { + if (!(key in source)) continue; + const value = knownItemsOnly(key, source[key]); + if (field.safeParse(value).success) salvaged[key] = value; + } + if (Object.keys(salvaged).length === 0) return DEFAULT_COACH_PREFS; + return coachPrefsSchema.parse(salvaged); +} + +/** The entries of a list preference this version knows; other values as is. */ +function knownItemsOnly(key: string, value: unknown): unknown { + if (!Array.isArray(value)) return value; + const item = + key === "excludeMetrics" + ? coachExcludeMetricEnum + : key === "dataClusters" + ? coachDataClusterEnum + : null; + return item ? value.filter((entry) => item.safeParse(entry).success) : value; } diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index a787761f5..1768dfe99 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -349,6 +349,21 @@ export interface DocumentConditionLinkDto { name: string; } +/** + * One procedure the vault can be filtered by. The visit's own words, decrypted + * server-side; the client composes the label the visits page shows (the + * reason, else the practice, else the kind) in the reader's locale. + */ +export interface DocumentLinkedProcedureDto { + encounterId: string; + /** ISO-8601 instant of the procedure. */ + occurredAt: string; + reason: string | null; + bodySite: string | null; + laterality: "LEFT" | "RIGHT" | "BOTH" | null; + practitionerName: string | null; +} + /** * One visit link on a document DTO — "this letter belongs to that appointment". * @@ -958,6 +973,14 @@ export interface DocumentUsageDto { * every linked document sits pages deep in the timeline. */ linkedEpisodes: DocumentConditionLinkDto[]; + /** + * Procedures and surgeries (visits of kind PROCEDURE) carrying at least one + * LIVE document link — the filter bar's procedure choices, sourced + * server-side for the same reason as `linkedEpisodes`. Empty when the + * caller's grant does not cover the visits section, because the label is + * the visit's own text. + */ + linkedProcedures: DocumentLinkedProcedureDto[]; /** * Whether the AI "Suggest details" action can run for this caller — true when * a provider (vision, or text + local OCR) is configured. The UI hides the diff --git a/src/lib/workouts/sport-context.ts b/src/lib/workouts/sport-context.ts index fee3cbce8..9e16828d0 100644 --- a/src/lib/workouts/sport-context.ts +++ b/src/lib/workouts/sport-context.ts @@ -12,6 +12,11 @@ * block reads the SAME figures the detail page shows. Two surfaces quoting * different averages for one sport is the drift this move removes. */ +import { + UNBOUNDED_REACH, + fitsReach, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; import { prisma } from "@/lib/db"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; @@ -32,7 +37,13 @@ export async function buildSportContext( sportType: string, sourcePriorityJson: unknown, excludeWorkoutId?: string, + /** + * The Coach's lookback limit. The comparison is a fixed 180 days; under a + * shorter limit it is left out rather than drawn from fewer sessions. + */ + reach: CoachHistoryReach = UNBOUNDED_REACH, ): Promise { + if (!fitsReach(SPORT_CONTEXT_LOOKBACK_DAYS, reach)) return null; const since = new Date( Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, ); diff --git a/tests/integration/coach-history-reach.test.ts b/tests/integration/coach-history-reach.test.ts new file mode 100644 index 000000000..d32a45b0c --- /dev/null +++ b/tests/integration/coach-history-reach.test.ts @@ -0,0 +1,251 @@ +/** + * The Coach lookback limit against real Postgres. + * + * The guard in `coach-history-reach-guard.test.ts` mocks every reader and + * proves the executor hands the limit on. This file proves the readers obey + * it: real builders, real SQL, an account with blood pressure from 200 days + * ago and from the last fortnight, weight and a lab panel only from 200 days + * ago, and a limit of 90 days saved in the account's Coach preferences. + * + * The old readings carry values that never occur in the recent ones (a + * systolic of 187, a weight of 93.7 kg, an LDL of 211), so any leak shows up + * as that number in what the Coach is given. The same calls with no limit + * ("All", the default) still return them, which pins that the default kept + * its behaviour. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { + buildCoachDataInventory, + renderDataInventory, +} from "@/lib/ai/coach/tools/inventory"; +import { __resetCoachSnapshotCacheForTests } from "@/lib/ai/coach/snapshot"; +import { readCoachReach } from "@/lib/ai/coach/history-reach-read"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; +import { assembleTurnContext } from "@/lib/ai/coach/turn/context"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const DAY = 86_400_000; +const OLD_SYS = 187; +const OLD_WEIGHT = 93.7; +const OLD_LDL = 211; + +let seq = 0; + +async function seedAccount(defaultWindow: string | null) { + seq += 1; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `history-reach-${seq}`, + email: `history-reach-${seq}@example.test`, + role: "USER", + timezone: "UTC", + createdAt: new Date(Date.now() - 400 * DAY), + ...(defaultWindow ? { coachPrefsJson: { defaultWindow } } : {}), + }, + }); + const now = Date.now(); + const bp = (daysAgo: number, sys: number, dia: number) => [ + { + userId: user.id, + type: "BLOOD_PRESSURE_SYS" as const, + value: sys, + unit: "mmHg", + measuredAt: new Date(now - daysAgo * DAY), + }, + { + userId: user.id, + type: "BLOOD_PRESSURE_DIA" as const, + value: dia, + unit: "mmHg", + measuredAt: new Date(now - daysAgo * DAY), + }, + ]; + await prisma.measurement.createMany({ + data: [ + ...bp(200, OLD_SYS, 109), + ...bp(201, OLD_SYS, 109), + ...bp(202, OLD_SYS, 109), + ...bp(10, 121, 79), + ...bp(11, 122, 78), + ...bp(12, 123, 77), + ...bp(13, 124, 76), + ...[200, 201, 202].map((daysAgo) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: OLD_WEIGHT, + unit: "kg", + measuredAt: new Date(now - daysAgo * DAY), + })), + ], + }); + await prisma.labResult.create({ + data: { + userId: user.id, + analyte: "LDL", + value: OLD_LDL, + unit: "mg/dL", + takenAt: new Date(now - 200 * DAY), + }, + }); + return user; +} + +function call( + userId: string, + name: string, + args: Record, + reach: Awaited>, +) { + return executeCoachTool({ + userId, + name, + rawArguments: JSON.stringify(args), + fallbackWindow: "allTime", + reach, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + __resetCoachSnapshotCacheForTests(); +}); +afterEach(() => { + __resetCoachSnapshotCacheForTests(); +}); + +describe("a 90-day lookback limit", () => { + it("reads the saved limit from the account's Coach preferences", async () => { + const user = await seedAccount("last90days"); + expect(await readCoachReach(user.id)).toEqual({ + window: "last90days", + days: 90, + }); + }); + + it("serves only the recent readings when the model asks for all time", async () => { + const user = await seedAccount("last90days"); + const reach = await readCoachReach(user.id); + const result = await call( + user.id, + "get_metric_series", + { metric: "bp", window: "allTime" }, + reach, + ); + expect(result.present).toBe(true); + const payload = JSON.stringify(result.data); + expect(payload).toContain("121"); + expect(payload).not.toContain(String(OLD_SYS)); + }); + + it("names a history entirely beyond the limit without any figure from it", async () => { + const user = await seedAccount("last90days"); + const reach = await readCoachReach(user.id); + const result = await call( + user.id, + "get_metric_series", + { metric: "weight", window: "allTime" }, + reach, + ); + expect(result).toMatchObject({ present: false, reason: "outside_reach" }); + expect(result.available).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain(String(OLD_WEIGHT)); + }); + + it("refuses a year-ago table and an earlier lab panel", async () => { + const user = await seedAccount("last90days"); + const reach = await readCoachReach(user.id); + const table = await call( + user.id, + "get_metric_table", + { metric: "bp", window: "last30days", period: "yearAgo" }, + reach, + ); + expect(table).toMatchObject({ present: false, reason: "outside_reach" }); + + const labs = await call(user.id, "get_labs", {}, reach); + expect(labs).toMatchObject({ present: false, reason: "outside_reach" }); + expect(JSON.stringify(labs)).not.toContain(String(OLD_LDL)); + }); + + it("marks the old domains beyond the lookback in the inventory, with no figures", async () => { + const user = await seedAccount("last90days"); + const reach = await readCoachReach(user.id); + const inventory = await buildCoachDataInventory( + user.id, + { window: "allTime" }, + reach, + ); + expect(inventory.window).toBe("last90days"); + expect(inventory.lookbackLimit).toBe("last90days"); + const weight = inventory.entries.find((e) => e.metric === "weight"); + expect(weight?.availability).toEqual({ + state: "outside_reach", + reachableWithWindow: null, + }); + const text = renderDataInventory(inventory); + expect(text).toContain("BEYOND LOOKBACK"); + expect(text).not.toContain(String(OLD_WEIGHT)); + expect(text).not.toContain(String(OLD_SYS)); + }); + + it("caps a wider window the client sends with the turn", async () => { + const user = await seedAccount("last90days"); + const ctx = await assembleTurnContext({ + userId: user.id, + locale: "en", + message: "How was my blood pressure?", + scope: { window: "allTime" }, + guidedQuestion: undefined, + workoutId: undefined, + conversation: { + conversationId: "history-reach-conversation", + priorTurns: [], + priorSummary: null, + } as never, + }); + expect(ctx.reach.days).toBe(90); + expect(ctx.effectiveScope?.window).toBe("last90days"); + expect(ctx.snapshot.snapshotJson).not.toContain(String(OLD_SYS)); + expect(ctx.snapshot.snapshotJson).not.toContain(String(OLD_WEIGHT)); + expect(ctx.snapshot.snapshotJson).not.toContain(String(OLD_LDL)); + }); +}); + +describe("no limit (the default)", () => { + it("still reads the whole history", async () => { + const user = await seedAccount(null); + const reach = await readCoachReach(user.id); + expect(reach).toEqual(UNBOUNDED_REACH); + + const bp = await call( + user.id, + "get_metric_series", + { metric: "bp", window: "allTime" }, + reach, + ); + expect(bp.present).toBe(true); + expect(JSON.stringify(bp.data)).toContain(String(OLD_SYS)); + + const labs = await call(user.id, "get_labs", {}, reach); + expect(labs.present).toBe(true); + expect(JSON.stringify(labs.data)).toContain(String(OLD_LDL)); + + const table = await call( + user.id, + "get_metric_table", + { metric: "bp", window: "last30days", period: "yearAgo" }, + reach, + ); + expect(table.reason).not.toBe("outside_reach"); + }); +}); diff --git a/tests/integration/daily-digest-today-overview.test.ts b/tests/integration/daily-digest-today-overview.test.ts new file mode 100644 index 000000000..844e73392 --- /dev/null +++ b/tests/integration/daily-digest-today-overview.test.ts @@ -0,0 +1,224 @@ +/** + * The Today overview on the daily digest, against real Postgres. + * + * The unit suites pin the selection rules on hand-built inputs. This one runs + * the whole read path (the dashboard snapshot, the coincident-deviation + * engine, the visit and episode tables, the cycle grid, the stored daily + * scores) on seeded rows, and checks that what comes out is a valid + * `DailyDigest` under the published OpenAPI schema. An account without any + * AI provider is the subject on purpose: the overview has to be whole + * without one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { dailyDigestResponse } from "@/lib/openapi/routes/daily"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TZ = "Europe/Berlin"; +const DAY = 24 * 60 * 60 * 1000; +// 08:00 in Berlin on a Sunday morning. +const NOW = new Date("2026-10-04T06:00:00.000Z"); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + __resetAllCachesForTests(); +}); + +async function seedUser( + id: string, + extra: Partial = {}, +) { + return getPrismaClient().user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: TZ, + gender: "FEMALE", + dateOfBirth: new Date("1985-05-05T00:00:00.000Z"), + ...extra, + }, + }); +} + +/** A month of steady resting heart rate and HRV, then an elevated morning. */ +async function seedVitals(userId: string) { + const rows: Prisma.MeasurementCreateManyInput[] = []; + for (let d = 30; d >= 1; d--) { + const at = new Date(NOW.getTime() - d * DAY); + rows.push( + { + userId, + type: "RESTING_HEART_RATE", + value: 52 + (d % 3), + unit: "bpm", + measuredAt: at, + source: "MANUAL", + }, + { + userId, + type: "HEART_RATE_VARIABILITY", + value: 48 + (d % 4), + unit: "ms", + measuredAt: at, + source: "MANUAL", + }, + ); + } + rows.push( + { + userId, + type: "RESTING_HEART_RATE", + value: 66, + unit: "bpm", + measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000), + source: "MANUAL", + }, + { + userId, + type: "HEART_RATE_VARIABILITY", + value: 49, + unit: "ms", + measuredAt: new Date(NOW.getTime() - 60 * 60 * 1000), + source: "MANUAL", + }, + ); + await getPrismaClient().measurement.createMany({ data: rows }); +} + +async function seedDay(userId: string, cycleTrackingEnabled?: boolean) { + const prisma = getPrismaClient(); + await seedVitals(userId); + // Unwell since Friday evening, Berlin time: day 3 on Sunday. + await prisma.illnessEpisode.create({ + data: { + userId, + label: "Cold", + type: "INFECTION", + onsetAt: new Date("2026-10-02T18:00:00.000Z"), + }, + }); + // Tomorrow 09:30 in Berlin. + await prisma.encounter.create({ + data: { + userId, + occurredAt: new Date("2026-10-05T07:30:00.000Z"), + status: "PLANNED", + kind: "ROUTINE", + }, + }); + // One logged period starting eleven days ago: day 12 today, phase still + // withheld while the engine learns. + await prisma.cycleProfile.create({ data: { userId, cycleTrackingEnabled } }); + await prisma.menstrualCycle.create({ + data: { + userId, + startDate: "2026-09-23", + periodEndDate: "2026-09-27", + tz: TZ, + }, + }); +} + +describe("the Today overview on a real record", () => { + it("publishes a valid digest with a deterministic lead and module-gated facts", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("today-owner"); + await seedDay(user.id); + const full = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + + const digest = await loadDailyDigest(full, NOW, { locale: "en" }); + + // The published contract holds, new fields included. + const parsed = dailyDigestResponse.safeParse( + JSON.parse(JSON.stringify(digest)), + ); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + + // No AI provider: no briefing text, and the lead is built from the + // morning's elevated resting heart rate. + expect(digest.briefingLead).toBeNull(); + expect(digest.lead?.source).toBe("signal"); + expect(digest.lead?.text).toMatch( + /^Resting heart rate is at 66 bpm, above your usual range of \d+ to \d+ bpm\.$/, + ); + + // Rest Mode passes through, counted on the Berlin calendar. + expect(digest.restMode).toEqual({ day: 3 }); + + // Tomorrow's visit is on the rail, so it is not repeated under Today; + // the vitals line became the lead, so it is not repeated either. + expect(digest.worthALook.some((i) => i.kind === "upcoming_visit")).toBe( + true, + ); + expect(digest.today.map((f) => f.kind)).toEqual(["rest_mode", "cycle"]); + expect(digest.today[0].value).toBe("Day 3"); + expect(digest.today[1].value).toBe("Day 12"); + }); + + it("names the visit under Today when the rail does not carry it", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("today-norail"); + await seedDay(user.id); + const full = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + + const digest = await loadDailyDigest(full, NOW, { + locale: "en", + enabledItemKinds: PRIORITY_ITEM_KINDS.filter( + (kind) => kind !== "upcoming_visit", + ), + }); + const appointment = digest.today.find((f) => f.kind === "appointment"); + expect(appointment?.value).toBe("Tomorrow 09:30 AM, Routine visit"); + }); + + it("says nothing about switched-off modules", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("today-modules-off", { + modulePreferencesJson: { illness: false }, + }); + // The cycle module follows the cycle profile's own switch, not the + // module preference blob. + await seedDay(user.id, false); + const full = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + + const digest = await loadDailyDigest(full, NOW, { locale: "en" }); + expect(digest.restMode).toBeNull(); + expect(digest.today.some((f) => f.kind === "rest_mode")).toBe(false); + expect(digest.today.some((f) => f.kind === "cycle")).toBe(false); + expect( + dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success, + ).toBe(true); + }); + + it("stays valid and empty for an account with nothing recorded", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("today-empty"); + const full = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + + const digest = await loadDailyDigest(full, NOW, { locale: "en" }); + expect(digest.lead).toBeNull(); + expect(digest.today).toEqual([]); + expect(digest.restMode).toBeNull(); + expect( + dailyDigestResponse.safeParse(JSON.parse(JSON.stringify(digest))).success, + ).toBe(true); + }); +}); diff --git a/tests/integration/documents-vault.test.ts b/tests/integration/documents-vault.test.ts index f9e0d8a7f..54abfc2aa 100644 --- a/tests/integration/documents-vault.test.ts +++ b/tests/integration/documents-vault.test.ts @@ -20,6 +20,9 @@ * 8. Bulk endpoint per-id semantics. * 9. Usage endpoint reflects overrides + tombstone-inclusive usage. * 10. The purge job hard-deletes only past-grace tombstones. + * 11. The procedure filter: the usage read lists the record's linked + * procedures (and nothing else, and nobody else's), and the list's + * `encounterId` filter narrows, combines and pages. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -28,6 +31,7 @@ process.env.ENCRYPTION_KEY ??= import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -774,3 +778,226 @@ describe("document vault — the sick note kind", () => { expect((await others.json()).data.documents).toHaveLength(0); }); }); + +describe("document vault — the procedure filter", () => { + it("lists linked procedures in usage and filters, combines and pages by encounterId", async () => { + const user = await seedVaultUser("vault-procedures"); + const prisma = getPrismaClient(); + const { post, get, del, getUsage } = await routes(); + + const clinic = await prisma.practitioner.create({ + data: { userId: user.id, name: "Day clinic" }, + }); + const arthroscopy = await prisma.encounter.create({ + data: { + userId: user.id, + kind: "PROCEDURE", + status: "DONE", + occurredAt: new Date("2025-10-02T08:00:00Z"), + reasonEncrypted: encryptToBytes("Knee arthroscopy"), + bodySiteEncrypted: encryptToBytes("Knee"), + laterality: "LEFT", + }, + }); + // No reason: the visits page heads it with the practice. + const unnamed = await prisma.encounter.create({ + data: { + userId: user.id, + kind: "PROCEDURE", + status: "DONE", + occurredAt: new Date("2024-05-10T08:00:00Z"), + practitionerId: clinic.id, + }, + }); + // Not a procedure: linked, but never a procedure choice. + const routine = await prisma.encounter.create({ + data: { + userId: user.id, + kind: "ROUTINE", + status: "DONE", + occurredAt: new Date("2025-03-01T08:00:00Z"), + }, + }); + // A deleted procedure and one whose only document is tombstoned drop out. + const deletedProcedure = await prisma.encounter.create({ + data: { + userId: user.id, + kind: "PROCEDURE", + status: "DONE", + occurredAt: new Date("2023-01-10T08:00:00Z"), + deletedAt: new Date(), + }, + }); + const orphaned = await prisma.encounter.create({ + data: { + userId: user.id, + kind: "PROCEDURE", + status: "DONE", + occurredAt: new Date("2022-01-10T08:00:00Z"), + }, + }); + // Linked to nothing: not a choice either. + await prisma.encounter.create({ + data: { + userId: user.id, + kind: "PROCEDURE", + status: "DONE", + occurredAt: new Date("2021-01-10T08:00:00Z"), + }, + }); + const knee = await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Knee", + type: "INJURY", + onsetAt: new Date("2025-09-01T00:00:00Z"), + }, + }); + + const upload = async ( + bytes: Buffer, + filename: string, + fields: Record, + ) => { + const res = await post(uploadRequest(bytes, filename, fields)); + expect(res.status).toBe(201); + return (await res.json()).data.id as string; + }; + const imaging = await upload(PDF_SMALL, "mri.pdf", { + kind: "IMAGING", + documentDate: "2025-10-01", + encounterIds: [arthroscopy.id], + episodeIds: [knee.id], + }); + const lab = await upload(PNG_1X1, "lab.png", { + kind: "LAB_RESULT", + documentDate: "2024-11-03", + encounterIds: [arthroscopy.id], + }); + await upload(Buffer.from("%PDF-1.7\n%unnamed\n%%EOF\n"), "op.pdf", { + kind: "OTHER", + documentDate: "2024-05-10", + encounterIds: [unnamed.id], + }); + await upload(Buffer.from("%PDF-1.7\n%routine\n%%EOF\n"), "r.pdf", { + documentDate: "2025-03-01", + encounterIds: [routine.id], + }); + await upload(Buffer.from("%PDF-1.7\n%loose\n%%EOF\n"), "loose.pdf", { + documentDate: "2025-06-01", + }); + // The deleted procedure's link is written below the API, which refuses a + // tombstoned visit; the orphan's only document is deleted after linking. + const forDeleted = await upload( + Buffer.from("%PDF-1.7\n%deleted\n%%EOF\n"), + "d.pdf", + {}, + ); + await prisma.encounterDocumentLink.create({ + data: { + encounterId: deletedProcedure.id, + documentId: forDeleted, + userId: user.id, + }, + }); + const forOrphan = await upload( + Buffer.from("%PDF-1.7\n%orphan\n%%EOF\n"), + "o.pdf", + { encounterIds: [orphaned.id] }, + ); + await del( + new Request(`http://localhost/api/documents/inbound/${forOrphan}`, { + method: "DELETE", + }), + ctx(forOrphan), + ); + + // Usage: exactly the two live, linked procedures, newest first, with + // the reason and site decrypted and the practice for the unnamed one. + const usage = ( + await ( + await getUsage( + new Request("http://localhost/api/documents/inbound/usage"), + ) + ).json() + ).data; + expect(usage.linkedProcedures).toEqual([ + { + encounterId: arthroscopy.id, + occurredAt: "2025-10-02T08:00:00.000Z", + reason: "Knee arthroscopy", + bodySite: "Knee", + laterality: "LEFT", + practitionerName: null, + }, + { + encounterId: unnamed.id, + occurredAt: "2024-05-10T08:00:00.000Z", + reason: null, + bodySite: null, + laterality: null, + practitionerName: "Day clinic", + }, + ]); + + const list = async (query: string) => { + const res = await get( + new Request(`http://localhost/api/documents/inbound?${query}`), + ); + expect(res.status).toBe(200); + return (await res.json()).data as { + documents: { id: string }[]; + nextCursor: string | null; + }; + }; + const ids = (page: { documents: { id: string }[] }) => + page.documents.map((d) => d.id).sort(); + + // The filter alone, then AND-combined with kind, year and condition. + expect(ids(await list(`encounterId=${arthroscopy.id}`))).toEqual( + [imaging, lab].sort(), + ); + expect( + ids(await list(`encounterId=${arthroscopy.id}&kind=IMAGING`)), + ).toEqual([imaging]); + expect(ids(await list(`encounterId=${arthroscopy.id}&year=2024`))).toEqual([ + lab, + ]); + expect( + ids(await list(`encounterId=${arthroscopy.id}&episodeId=${knee.id}`)), + ).toEqual([imaging]); + expect( + ids(await list(`encounterId=${unnamed.id}&episodeId=${knee.id}`)), + ).toEqual([]); + + // Keyset pagination stays inside the filter: newest filing date first. + const first = await list(`encounterId=${arthroscopy.id}&limit=1`); + expect(first.documents.map((d) => d.id)).toEqual([imaging]); + expect(first.nextCursor).toBe(imaging); + const second = await list( + `encounterId=${arthroscopy.id}&limit=1&cursor=${first.nextCursor}`, + ); + expect(second.documents.map((d) => d.id)).toEqual([lab]); + expect(second.nextCursor).toBeNull(); + + // A tombstoned document never answers the filter. + expect(ids(await list(`encounterId=${orphaned.id}`))).toEqual([]); + + // Another account: the owner's procedure id matches nothing of theirs, + // and their usage read lists no procedure of the owner's. + cookieJar.clear(); + await seedVaultUser("vault-procedures-stranger"); + await upload(Buffer.from("%PDF-1.7\n%stranger\n%%EOF\n"), "s.pdf", {}); + const foreign = await list(`encounterId=${arthroscopy.id}`); + expect(foreign.documents).toEqual([]); + expect(foreign.nextCursor).toBeNull(); + const strangerUsage = ( + await ( + await getUsage( + new Request("http://localhost/api/documents/inbound/usage"), + ) + ).json() + ).data; + expect(strangerUsage.linkedProcedures).toEqual([]); + }); +});