mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
202 files changed
+14166
-1789
No files matched your search
@@ -2,6 +2,36 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.22.0] — 2026-06-27 — A Coach that speaks, remembers, and charts; richer narratives across the app
|
||||
|
||||
A feature release. The Coach reads its replies aloud, can draw the metric it is discussing, remembers what you ask it to and resurfaces those reminders, and proposes checkups or measurement reminders you confirm inline. Insights and Coach assessments are rewritten for connected, motivating narratives instead of restated figures, and the daily read now folds in labs, preventive care, workouts, and glucose. Four additive migrations (`0195` hidden biomarkers, `0196` coach reminders, `0197` coach plan outcomes, `0198` AI settings); applied automatically on start — no operator action required.
|
||||
|
||||
### Added
|
||||
|
||||
- The Coach can read its replies aloud, and a reply can include a chart of the metric under discussion so the number has its trend alongside it.
|
||||
- The Coach remembers things you ask it to keep and resurfaces a reminder when it comes due; a reminders list in settings shows what it is holding and lets you clear any of it.
|
||||
- The Coach can propose a checkup or a measurement reminder, which you confirm inline before anything is saved.
|
||||
- Hover a message to see the date and time it was sent.
|
||||
- A configurable AI response timeout, and a separate optional provider dedicated to scanning lab documents.
|
||||
- Steps now appear in Insights, and selecting a measurement type opens its insight.
|
||||
|
||||
### Changed
|
||||
|
||||
- Insight and Coach assessments are rewritten to connect signals into a motivating narrative rather than restate figures, with substantially expanded score summaries; generated text now renders in paragraphs and carries clearer "updated" timestamps — today with the time, yesterday, and a date for anything older.
|
||||
- The daily read folds in labs, preventive care, workouts, and glucose, and correlations surface emerging signals over a recent window alongside more cross-signal links.
|
||||
- The preventive-care area is now "Checkups", reached at `/checkups`; the German interface keeps "Vorsorge".
|
||||
- Settings pages share one consistent layout, and the settings and admin sidebar stays pinned while the content scrolls.
|
||||
- Lab biomarkers are a compact, editable list with hide and restore.
|
||||
- Local AI servers stream their replies and honour the configurable timeout, so slower self-hosted models finish cleanly.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The daily briefing and the insight trend no longer go blank when generation runs long.
|
||||
- The blood-pressure "usual range" no longer shows a nonsensical band.
|
||||
- A Coach notification now opens straight into the conversation it began.
|
||||
- Insight headings drop the trailing info icon in favour of a short description.
|
||||
- Lab values no longer link out to an article.
|
||||
|
||||
## [1.21.4] — 2026-06-27 — Coach surface refinements
|
||||
|
||||
A patch release. No migrations, no breaking changes.
|
||||
|
||||
+663
-2
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.21.4
|
||||
version: 1.22.0
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -4676,6 +4676,8 @@ paths:
|
||||
- active
|
||||
- met
|
||||
- abandoned
|
||||
- review_due
|
||||
- reviewed
|
||||
description: Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).
|
||||
responses:
|
||||
"200":
|
||||
@@ -4724,6 +4726,8 @@ paths:
|
||||
- active
|
||||
- met
|
||||
- abandoned
|
||||
- review_due
|
||||
- reviewed
|
||||
reviewDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -4836,6 +4840,189 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/coach/reminders:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: List the caller's Coach reminders
|
||||
description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly,
|
||||
soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted
|
||||
returns the non-terminal set (proposed + active + due + surfaced). Coach-gated
|
||||
(`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session.
|
||||
Undecryptable rows are omitted.
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit
|
||||
for the non-terminal set.
|
||||
responses:
|
||||
"200":
|
||||
description: The caller's reminders.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachRemindersList"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Create a Coach reminder manually
|
||||
description: v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed
|
||||
grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The
|
||||
per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is
|
||||
narrowed from the session.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: The created reminder.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderCreated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The per-user reminder cap is full.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/coach/reminders/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Confirm or update a Coach reminder's lifecycle
|
||||
description: v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the
|
||||
closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown /
|
||||
already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie
|
||||
or Bearer.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Reminder id.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: The reminder after the lifecycle update.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderUpdated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: Reminder not found or not owned by the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Soft-delete one Coach reminder
|
||||
description: "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted
|
||||
id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Reminder id.
|
||||
responses:
|
||||
"200":
|
||||
description: "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted:
|
||||
false`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderDeleted"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/coach/suggested-actions:
|
||||
post:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Confirm a Coach action card
|
||||
description: v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED
|
||||
allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a
|
||||
preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE
|
||||
server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm.
|
||||
Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner
|
||||
is narrowed from the session, built field-by-field — no mass assignment, no IDOR.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSuggestedAction"
|
||||
responses:
|
||||
"201":
|
||||
description: The action was applied (entity created server-side).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSuggestedActionResultCreated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The per-user reminder cap is full (reminder.note).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/admin/invites:
|
||||
get:
|
||||
tags:
|
||||
@@ -6741,6 +6928,8 @@ paths:
|
||||
- type: string
|
||||
const: ""
|
||||
- type: "null"
|
||||
hidden:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: Updated biomarker.
|
||||
@@ -8536,6 +8725,99 @@ components:
|
||||
compatibility and are normalised to their English equivalents before persisting; GET responses always carry the
|
||||
canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a
|
||||
future major version."
|
||||
CoachReminderCreate:
|
||||
type: object
|
||||
properties:
|
||||
note:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 280
|
||||
when:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
metric:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 60
|
||||
pattern: ^[A-Za-z][A-Za-z0-9_]*$
|
||||
required:
|
||||
- note
|
||||
additionalProperties: false
|
||||
description: "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional
|
||||
closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue
|
||||
(NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side.
|
||||
Omit `when` for a recall-only note. Strict: unknown keys 422."
|
||||
CoachReminderPatch:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- proposed
|
||||
- active
|
||||
- due
|
||||
- surfaced
|
||||
- done
|
||||
- dismissed
|
||||
when:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
- type: "null"
|
||||
additionalProperties: false
|
||||
description: "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed.
|
||||
`when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict:
|
||||
unknown keys 422."
|
||||
CoachSuggestedAction:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
actionType:
|
||||
type: string
|
||||
const: checkup.create
|
||||
label:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
interval:
|
||||
type: string
|
||||
enum:
|
||||
- yearly
|
||||
- halfYearly
|
||||
- quarterly
|
||||
- monthly
|
||||
required:
|
||||
- actionType
|
||||
- label
|
||||
- interval
|
||||
- type: object
|
||||
properties:
|
||||
actionType:
|
||||
type: string
|
||||
const: reminder.note
|
||||
note:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 280
|
||||
when:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
metric:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 60
|
||||
pattern: ^[A-Za-z][A-Za-z0-9_]*$
|
||||
required:
|
||||
- actionType
|
||||
- note
|
||||
description: Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note);
|
||||
NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text
|
||||
label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note +
|
||||
optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm.
|
||||
type: object
|
||||
CoachMessageFeedbackRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -14092,6 +14374,43 @@ components:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: "`...` + last 4 chars of the stored OpenAI key, or null."
|
||||
responseTimeoutSeconds:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous
|
||||
built-in default (~180 s). Mainly for slow local/self-hosted backends.
|
||||
ocrEnabled:
|
||||
type: boolean
|
||||
description: v1.22 (#90) — whether a dedicated document-scan (Lab-OCR) provider is active. When false the OCR path uses
|
||||
the main provider chain.
|
||||
ocrProvider:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- OPENAI
|
||||
- ANTHROPIC
|
||||
- LOCAL
|
||||
- type: "null"
|
||||
description: The dedicated document-scan provider, or null when unset.
|
||||
ocrModel:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
ocrBaseUrl:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Custom base URL (LOCAL OCR provider only); null otherwise.
|
||||
hasOcrKey:
|
||||
type: boolean
|
||||
ocrKeyPreview:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: "`...` + last 4 chars of the stored document-scan provider key, or null."
|
||||
required:
|
||||
- provider
|
||||
- model
|
||||
@@ -14103,6 +14422,13 @@ components:
|
||||
- hasLocalKey
|
||||
- hasOpenaiKey
|
||||
- openaiKeyPreview
|
||||
- responseTimeoutSeconds
|
||||
- ocrEnabled
|
||||
- ocrProvider
|
||||
- ocrModel
|
||||
- ocrBaseUrl
|
||||
- hasOcrKey
|
||||
- ocrKeyPreview
|
||||
additionalProperties: false
|
||||
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
|
||||
Reports key presence + a masked preview only — never a plaintext key.
|
||||
@@ -16652,9 +16978,16 @@ components:
|
||||
unread:
|
||||
type: boolean
|
||||
description: True when that message is newer than the last time the caller opened the Coach.
|
||||
conversationId:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Conversation holding the newest assistant message; null when none exists. The client deep-links into it
|
||||
(`/coach?c=<id>`) on the unread path.
|
||||
required:
|
||||
- nudgedAt
|
||||
- unread
|
||||
- conversationId
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
@@ -16923,6 +17256,8 @@ components:
|
||||
- active
|
||||
- met
|
||||
- abandoned
|
||||
- review_due
|
||||
- reviewed
|
||||
description: "App-side lifecycle: proposed | active | met | abandoned."
|
||||
reviewDate:
|
||||
anyOf:
|
||||
@@ -17062,6 +17397,208 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachRemindersList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminders:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
description: The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the
|
||||
non-terminal set (proposed + active + due + surfaced).
|
||||
required:
|
||||
- reminders
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminder:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Decrypted note (the user's own framing). Null when the row's key id is no longer in the map.
|
||||
metric:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
triggerKind:
|
||||
type: string
|
||||
enum:
|
||||
- date
|
||||
- context
|
||||
dueAt:
|
||||
anyOf:
|
||||
- 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)))$
|
||||
- type: "null"
|
||||
contextCue:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- proposed
|
||||
- active
|
||||
- due
|
||||
- surfaced
|
||||
- done
|
||||
- dismissed
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- sentinel
|
||||
- extractor
|
||||
- manual
|
||||
- action
|
||||
createdAt:
|
||||
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)))$
|
||||
updatedAt:
|
||||
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)))$
|
||||
required:
|
||||
- id
|
||||
- note
|
||||
- metric
|
||||
- triggerKind
|
||||
- dueAt
|
||||
- contextCue
|
||||
- status
|
||||
- source
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has
|
||||
rotated out of the map.
|
||||
CoachReminderCreated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminder:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
required:
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminderUpdated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminder:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
required:
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminderDeleted:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: boolean
|
||||
description: True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user /
|
||||
already-deleted id (idempotent no-op).
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSuggestedActionResultCreated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CoachSuggestedActionResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSuggestedActionResult:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
const: true
|
||||
actionType:
|
||||
type: string
|
||||
enum:
|
||||
- checkup.create
|
||||
- reminder.note
|
||||
reminder: {}
|
||||
reminderId:
|
||||
type: string
|
||||
required:
|
||||
- ok
|
||||
- actionType
|
||||
additionalProperties: false
|
||||
description: Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO;
|
||||
`reminder.note` returns the created CoachReminder id.
|
||||
AdminInviteList:
|
||||
type: object
|
||||
properties:
|
||||
@@ -18622,6 +19159,14 @@ components:
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Minimum paired-day count enforced per pair.
|
||||
emerging:
|
||||
description: v1.22 — rolling early-detection pass over the trailing window; emerging pairs not yet established
|
||||
retrospectively (no double-count).
|
||||
$ref: "#/components/schemas/EmergingCorrelationResult"
|
||||
labCorrelations:
|
||||
description: v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled;
|
||||
absent-degrading on sparse draws.
|
||||
$ref: "#/components/schemas/LabCorrelationResult"
|
||||
required:
|
||||
- discovered
|
||||
- pairsTested
|
||||
@@ -18661,6 +19206,16 @@ components:
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Lag in days applied (1).
|
||||
window:
|
||||
description: v1.22 — which window surfaced the pair. Absent on the 180-day scan (retrospective default); `recent` for an
|
||||
emerging early-detection pair.
|
||||
type: string
|
||||
enum:
|
||||
- retrospective
|
||||
- recent
|
||||
provisional:
|
||||
description: "v1.22 — true for an emerging recent-window pair: fewer days, hedged as provisional rather than established."
|
||||
type: boolean
|
||||
required:
|
||||
- behaviour
|
||||
- outcome
|
||||
@@ -18671,6 +19226,107 @@ components:
|
||||
- interpretation
|
||||
- lagDays
|
||||
additionalProperties: false
|
||||
EmergingCorrelationResult:
|
||||
type: object
|
||||
properties:
|
||||
emerging:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DiscoveredCorrelation"
|
||||
description: Recent-window pairs NOT already established retrospectively — the emerging signals (provisional, hedged).
|
||||
windowDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Trailing window (days) the early pass scanned.
|
||||
minPairs:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Paired-day floor enforced for the early pass.
|
||||
fdrQ:
|
||||
type: number
|
||||
description: FDR target the early pass used (tighter than the main scan).
|
||||
pairsTested:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Pairs tested in the early window.
|
||||
required:
|
||||
- emerging
|
||||
- windowDays
|
||||
- minPairs
|
||||
- fdrQ
|
||||
- pairsTested
|
||||
additionalProperties: false
|
||||
LabCorrelationResult:
|
||||
type: object
|
||||
properties:
|
||||
discovered:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DiscoveredLabCorrelation"
|
||||
description: Lab ↔ outcome associations surviving the per-pair floor + BH-FDR.
|
||||
pairsTested:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Lab × outcome pairs assessed.
|
||||
fdrQ:
|
||||
type: number
|
||||
description: The FDR target the pass used.
|
||||
minDraws:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Minimum paired-draw count enforced per pair.
|
||||
required:
|
||||
- discovered
|
||||
- pairsTested
|
||||
- fdrQ
|
||||
- minDraws
|
||||
additionalProperties: false
|
||||
DiscoveredLabCorrelation:
|
||||
type: object
|
||||
properties:
|
||||
lab:
|
||||
type: string
|
||||
description: "`LAB:<analyte>` channel key (display strips the prefix)."
|
||||
outcome:
|
||||
type: string
|
||||
description: Outcome channel the marker tracks with (WEIGHT, BLOOD_GLUCOSE, BLOOD_PRESSURE_SYS).
|
||||
n:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Draws paired with a usable contemporaneous outcome window.
|
||||
r:
|
||||
type: number
|
||||
description: Pearson r over (draw value, contemporaneous outcome window-mean).
|
||||
pValue:
|
||||
type: number
|
||||
description: Two-sided exact Student-t p-value (< 0.05).
|
||||
qValue:
|
||||
type: number
|
||||
description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).
|
||||
windowDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Trailing days each draw's outcome window spanned.
|
||||
interpretation:
|
||||
type: string
|
||||
description: Conservative, descriptive interpretation — never causal.
|
||||
required:
|
||||
- lab
|
||||
- outcome
|
||||
- n
|
||||
- r
|
||||
- pValue
|
||||
- qValue
|
||||
- windowDays
|
||||
- interpretation
|
||||
additionalProperties: false
|
||||
MoodTagTreeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -20212,6 +20868,8 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
hidden:
|
||||
type: boolean
|
||||
createdAt:
|
||||
type: string
|
||||
updatedAt:
|
||||
@@ -20225,11 +20883,14 @@ components:
|
||||
- panel
|
||||
- hasContext
|
||||
- context
|
||||
- hidden
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
|
||||
presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.
|
||||
presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it
|
||||
(its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds
|
||||
from here.
|
||||
CreateBiomarkerResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"src/proxy.ts",
|
||||
"src/instrumentation.ts",
|
||||
"src/lib/jobs/**/*.ts",
|
||||
"src/lib/ai/coach/eval/judge.ts",
|
||||
"scripts/**/*.ts",
|
||||
"scripts/**/*.mjs",
|
||||
"e2e/**/*.spec.ts",
|
||||
|
||||
+106
-5
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Vorherige Seite",
|
||||
"nextPage": "Nächste Seite",
|
||||
"dailyTotalCaption": "({count} Einzelwerte)",
|
||||
"openMetricInsights": "{type}-Auswertung öffnen",
|
||||
"sleepNapCaption": "+{count} Nickerchen",
|
||||
"sleepAwakeningsCaption": "{count} Wachphasen",
|
||||
"expandDay": "Einzelwerte dieses Tages anzeigen",
|
||||
@@ -1945,7 +1946,6 @@
|
||||
"confidenceMittel": "Mittel",
|
||||
"confidenceGering": "Gering",
|
||||
"loadError": "Auswertungen konnten nicht geladen werden",
|
||||
"heroGenerated": "Erstellt {time}",
|
||||
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
|
||||
"heroRegenerate": "Neu generieren",
|
||||
"heroRegenerating": "Wird generiert",
|
||||
@@ -1957,7 +1957,6 @@
|
||||
"narrativeMonth": "Dieser Monat",
|
||||
"narrativePreparing": "Deine Zusammenfassung wird erstellt…",
|
||||
"narrativeUpdating": "Wird aktualisiert…",
|
||||
"narrativeUpdated": "Aktualisiert {time}",
|
||||
"narrativeProvenanceLabel": "Wie das zustande kam",
|
||||
"narrativeProvenanceMethod": "Eine Zusammenfassung des Zeitraums in einfacher Sprache, ausschließlich aus deinen erfassten Werten und den Zusammenhängen, die die statistische Mehrfachvergleichskorrektur überstanden haben. Beschreibend, nie eine Ursache.",
|
||||
"narrativeProvenanceMetrics": "Basierend auf: {metrics}",
|
||||
@@ -2098,6 +2097,9 @@
|
||||
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
|
||||
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
|
||||
"fabLabel": "Coach öffnen",
|
||||
"readAloud": "Vorlesen",
|
||||
"readAloudStop": "Vorlesen beenden",
|
||||
"messageTimeLabel": "Nachricht gesendet {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Antwort in deine Selbstauskunft übernehmen?",
|
||||
"confirm": "Übernehmen",
|
||||
@@ -2320,6 +2322,33 @@
|
||||
"groupYesterday": "Gestern",
|
||||
"groupThisWeek": "Diese Woche",
|
||||
"groupEarlier": "Früher"
|
||||
},
|
||||
"seed": {
|
||||
"bloodPressure": "Geh mit mir meinen Blutdruckverlauf der letzten 30 Tage durch — gibt es etwas, das ich im Auge behalten sollte?",
|
||||
"weight": "Wie hat sich mein Gewicht zuletzt entwickelt, und ist die Richtung etwas, worauf ich reagieren sollte?",
|
||||
"bmi": "Was sagt mir mein BMI-Verlauf, und wie sollte ich ihn lesen?",
|
||||
"pulse": "Geh mit mir meine Pulswerte durch — ist irgendetwas ungewöhnlich?",
|
||||
"restingHr": "Wie hat sich mein Ruhepuls entwickelt, und was sagt er über meine Fitness?",
|
||||
"hrv": "Was sagt mir meine Herzfrequenzvariabilität zuletzt über Erholung und Stress?",
|
||||
"sleep": "Geh mit mir meinen letzten Schlaf durch — Dauer, Regelmäßigkeit und was sich zu ändern lohnt.",
|
||||
"mood": "Welche Muster siehst du in meiner Stimmung über die letzten Wochen?",
|
||||
"medications": "Wie sieht meine Medikamentenadhärenz aus, und passt sie dazu, wie ich mich gefühlt habe?",
|
||||
"steps": "Wie aktiv war ich zuletzt gemessen an meinen Schritten, und wie ist das im Vergleich zu einem gesunden Richtwert?",
|
||||
"activeEnergy": "Was sagt mein Aktivenergie-Verlauf über mein Aktivitätsniveau zuletzt?",
|
||||
"cardioFitness": "Was bedeutet mein Cardio-Fitness-Verlauf (VO₂max), und wie bewege ich ihn in die richtige Richtung?",
|
||||
"bloodGlucose": "Geh mit mir meine letzten Glukosewerte durch — liegen sie in einem gesunden Bereich?",
|
||||
"oxygenSaturation": "Was sagt mir mein Sauerstoffsättigungs-Verlauf (SpO₂)?",
|
||||
"respiratoryRate": "Was sagt mein Atemfrequenz-Verlauf über meine Gesundheit?",
|
||||
"workouts": "Geh mit mir meine letzten Workouts durch — Belastung, Häufigkeit und wie ich mich dazwischen erhole.",
|
||||
"recoveryPage": "Warum steht meine Erholung gerade dort, wo sie steht, und was treibt das?",
|
||||
"briefing": "Geh mit mir das heutige Briefing durch — worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?",
|
||||
"healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?",
|
||||
"periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief — was sticht heraus?",
|
||||
"periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief — was sticht heraus?",
|
||||
"correlationBpCompliance": "Wie hängt meine Medikamentenadhärenz mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.",
|
||||
"correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.",
|
||||
"correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?",
|
||||
"recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
@@ -3240,6 +3269,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Deine Vitalwerte",
|
||||
"sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline",
|
||||
"loadingLabel": "Vitalwerte werden geladen",
|
||||
"loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.",
|
||||
"building": "Typischer Bereich entsteht — {count} von {target} Tagen",
|
||||
@@ -3366,6 +3396,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Deine Gesundheitswerte",
|
||||
"sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten",
|
||||
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
|
||||
"recovery": "Erholung",
|
||||
"stress": "Stress",
|
||||
@@ -3611,7 +3642,10 @@
|
||||
"returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei.",
|
||||
"daysInside": "{count} Tage drinnen",
|
||||
"daysOutside": "{count} Tage draußen"
|
||||
}
|
||||
},
|
||||
"updatedTodayAt": "Heute aktualisiert, {time}",
|
||||
"updatedYesterday": "Gestern aktualisiert",
|
||||
"updatedOn": "Aktualisiert am {date}"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -4413,6 +4447,28 @@
|
||||
"clearedToast": "{count} gespeicherte Angaben vergessen.",
|
||||
"clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Deine Erinnerungen",
|
||||
"description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen. Fällige erscheinen hier und beim Coach.",
|
||||
"empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern — dann erscheint es hier.",
|
||||
"loadError": "Konnte deine Erinnerungen nicht laden.",
|
||||
"dueHeading": "Jetzt fällig",
|
||||
"allHeading": "Gespeichert",
|
||||
"duePrefix": "fällig {when}",
|
||||
"confirm": "Behalten",
|
||||
"markDone": "Erledigt",
|
||||
"dismiss": "Verwerfen",
|
||||
"deleteAria": "Diese Erinnerung löschen",
|
||||
"note": "Erinnerungen werden verschlüsselt gespeichert. Lösche eine jederzeit — sie wird aus dem entfernt, was der Coach sehen kann.",
|
||||
"status": {
|
||||
"proposed": "Vorgeschlagen",
|
||||
"active": "Gespeichert",
|
||||
"due": "Fällig",
|
||||
"surfaced": "Angezeigt",
|
||||
"done": "Erledigt",
|
||||
"dismissed": "Verworfen"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Über mich",
|
||||
"description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.",
|
||||
@@ -4436,7 +4492,24 @@
|
||||
"completeness": "{answered} von {total} beantwortet",
|
||||
"questionsTitle": "Der Coach hat Rückfragen",
|
||||
"questionsOpenCoach": "Im Coach beantworten"
|
||||
}
|
||||
},
|
||||
"responseTimeoutHeading": "Antwort-Timeout",
|
||||
"responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.",
|
||||
"responseTimeoutLabel": "Antwort-Timeout (Sekunden)",
|
||||
"responseTimeoutPlaceholder": "Standard (~180)",
|
||||
"responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.",
|
||||
"ocrHeading": "Dokumentenscan (Laborberichte)",
|
||||
"ocrBody": "Verwende einen eigenen Anbieter zum Auslesen hochgeladener Laborberichte. Ist die Option aus, nutzt der Scan deinen Hauptanbieter.",
|
||||
"ocrEnableLabel": "Eigenen Anbieter für den Dokumentenscan aktivieren",
|
||||
"ocrProviderLabel": "Scan-Anbieter",
|
||||
"ocrProviderOption": {
|
||||
"OPENAI": "OpenAI (dein API-Key)",
|
||||
"ANTHROPIC": "Anthropic (Claude)",
|
||||
"LOCAL": "Lokales Modell (OpenAI-kompatibel)"
|
||||
},
|
||||
"ocrModelPlaceholder": "z. B. gpt-4o",
|
||||
"ocrVisionHint": "Dieses Modell kann möglicherweise keine Bilder lesen. Wähle für den Dokumentenscan ein Modell mit Bildverständnis.",
|
||||
"ocrKeyLabel": "API-Key"
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -4663,6 +4736,8 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"identityDescription": "Version, Lizenz und Build-Details dieser Instanz.",
|
||||
"linksDescription": "Quellcode, Änderungsprotokoll und Dokumentation.",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -6720,7 +6795,14 @@
|
||||
"savedToast": "Biomarker gespeichert",
|
||||
"updatedToast": "Biomarker aktualisiert",
|
||||
"saveError": "Biomarker konnte nicht gespeichert werden"
|
||||
}
|
||||
},
|
||||
"hide": "Ausblenden",
|
||||
"unhide": "Wiederherstellen",
|
||||
"hiddenToast": "Biomarker ausgeblendet",
|
||||
"unhiddenToast": "Biomarker wiederhergestellt",
|
||||
"hideError": "Biomarker konnte nicht aktualisiert werden",
|
||||
"allHidden": "Alle Biomarker sind ausgeblendet.",
|
||||
"hiddenSection": "Ausgeblendet"
|
||||
},
|
||||
"catalog": {
|
||||
"total-cholesterol": "Gesamtcholesterin",
|
||||
@@ -7041,6 +7123,25 @@
|
||||
"glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Hinzufügen",
|
||||
"dismiss": "Jetzt nicht",
|
||||
"applied": "Hinzugefügt",
|
||||
"dismissed": "Verworfen",
|
||||
"failed": "Konnte das nicht hinzufügen — bitte erneut versuchen",
|
||||
"checkup": {
|
||||
"title": "Vorsorge-Erinnerung hinzufügen?"
|
||||
},
|
||||
"note": {
|
||||
"title": "Erinnerung speichern?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Jedes Jahr",
|
||||
"halfYearly": "Alle sechs Monate",
|
||||
"quarterly": "Alle drei Monate",
|
||||
"monthly": "Jeden Monat"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Kadenz-Vorschläge",
|
||||
"reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
|
||||
|
||||
+118
-17
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
"vorsorge": "Preventive Care",
|
||||
"vorsorge": "Checkups",
|
||||
"illness": "Illness"
|
||||
},
|
||||
"auth": {
|
||||
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"dailyTotalCaption": "({count} samples)",
|
||||
"openMetricInsights": "Open {type} insights",
|
||||
"sleepNapCaption": "+{count} nap(s)",
|
||||
"sleepAwakeningsCaption": "{count} awakening(s)",
|
||||
"expandDay": "Show samples for this day",
|
||||
@@ -1945,7 +1946,6 @@
|
||||
"confidenceMittel": "Medium",
|
||||
"confidenceGering": "Low",
|
||||
"loadError": "Failed to load insights",
|
||||
"heroGenerated": "Generated {time}",
|
||||
"heroPersonalBaseline": "Based on your last 90 days",
|
||||
"heroRegenerate": "Regenerate",
|
||||
"heroRegenerating": "Regenerating",
|
||||
@@ -1957,7 +1957,6 @@
|
||||
"narrativeMonth": "This month",
|
||||
"narrativePreparing": "Preparing your summary…",
|
||||
"narrativeUpdating": "Updating…",
|
||||
"narrativeUpdated": "Updated {time}",
|
||||
"narrativeProvenanceLabel": "How this was put together",
|
||||
"narrativeProvenanceMethod": "A plain-language summary of the period, drawn only from your logged numbers and the associations that survived statistical multiple-comparison control. Descriptive, never a cause.",
|
||||
"narrativeProvenanceMetrics": "Based on: {metrics}",
|
||||
@@ -2098,6 +2097,9 @@
|
||||
"composerPlaceholder": "Ask anything about your data…",
|
||||
"nudgeBubbleLabel": "New message from the Coach — open chat",
|
||||
"fabLabel": "Open the Coach",
|
||||
"readAloud": "Read aloud",
|
||||
"readAloudStop": "Stop reading",
|
||||
"messageTimeLabel": "Message sent {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Add this answer to your self-context?",
|
||||
"confirm": "Add",
|
||||
@@ -2320,6 +2322,33 @@
|
||||
"groupYesterday": "Yesterday",
|
||||
"groupThisWeek": "This week",
|
||||
"groupEarlier": "Earlier"
|
||||
},
|
||||
"seed": {
|
||||
"bloodPressure": "Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?",
|
||||
"weight": "How has my weight been trending lately, and is the direction something to act on?",
|
||||
"bmi": "What does my BMI trend tell me, and how should I read it?",
|
||||
"pulse": "Walk me through my pulse readings — is anything out of the ordinary?",
|
||||
"restingHr": "How has my resting heart rate been trending, and what does it say about my fitness?",
|
||||
"hrv": "What is my heart-rate variability telling me about recovery and stress lately?",
|
||||
"sleep": "Walk me through my recent sleep — duration, consistency, and anything worth changing.",
|
||||
"mood": "What patterns do you see in my mood over the last few weeks?",
|
||||
"medications": "How is my medication adherence looking, and is it lined up with how I've been feeling?",
|
||||
"steps": "How active have I been lately based on my steps, and how does it compare to a healthy baseline?",
|
||||
"activeEnergy": "What does my active-energy trend say about my activity level lately?",
|
||||
"cardioFitness": "What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?",
|
||||
"bloodGlucose": "Walk me through my recent glucose readings — are they in a healthy range?",
|
||||
"oxygenSaturation": "What does my blood-oxygen (SpO₂) trend tell me?",
|
||||
"respiratoryRate": "What does my respiratory-rate trend say about my health?",
|
||||
"workouts": "Walk me through my recent workouts — load, frequency, and how I'm recovering between them.",
|
||||
"recoveryPage": "Why is my recovery where it is right now, and what's driving it?",
|
||||
"briefing": "Walk me through today's briefing — what should I focus on, and is anything worth acting on?",
|
||||
"healthScore": "Why is my health score {score} out of 100, and what would move it the most?",
|
||||
"periodWeek": "Walk me through how my last week went across my health metrics — what stands out?",
|
||||
"periodMonth": "Walk me through how my last month went across my health metrics — what stands out?",
|
||||
"correlationBpCompliance": "How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.",
|
||||
"correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.",
|
||||
"correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
|
||||
"recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
@@ -3240,6 +3269,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Your vitals",
|
||||
"sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline",
|
||||
"loadingLabel": "Loading your vitals",
|
||||
"loadError": "Could not load your vitals right now.",
|
||||
"building": "Building your typical range — {count} of {target} days",
|
||||
@@ -3366,6 +3396,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Your health scores",
|
||||
"sectionSubtitle": "Composite recovery, stress and strain read from your connected devices",
|
||||
"loadError": "Could not load your wellness scores right now.",
|
||||
"recovery": "Recovery",
|
||||
"stress": "Stress",
|
||||
@@ -3611,7 +3642,10 @@
|
||||
"returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed.",
|
||||
"daysInside": "{count} days inside",
|
||||
"daysOutside": "{count} days outside"
|
||||
}
|
||||
},
|
||||
"updatedTodayAt": "Updated today, {time}",
|
||||
"updatedYesterday": "Updated yesterday",
|
||||
"updatedOn": "Updated on {date}"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -3677,8 +3711,8 @@
|
||||
"body": "The illness journal records acute episodes and chronic diagnoses with their flares. Hidden: correlate an episode with your readings, and Rest Mode that pauses reminders. That’s how the Coach understands why values drift for a while."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Preventive care",
|
||||
"body": "Preventive care reminds you of check-ups and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report."
|
||||
"title": "Checkups",
|
||||
"body": "Checkups remind you of screenings and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
@@ -3702,7 +3736,7 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "Doctor report",
|
||||
"body": "The doctor report bundles readings, medications, lab values, and preventive care as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data."
|
||||
"body": "The doctor report bundles readings, medications, lab values, and checkups as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
@@ -4132,8 +4166,8 @@
|
||||
"subtitle": "Manage the view and order of episodes."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Preventive care",
|
||||
"description": "View, order, and individual preventive-care reminders.",
|
||||
"title": "Checkups",
|
||||
"description": "View, order, and individual checkup reminders.",
|
||||
"subtitle": "Manage the view, order, and individual reminders."
|
||||
},
|
||||
"modules": {
|
||||
@@ -4413,6 +4447,28 @@
|
||||
"clearedToast": "Forgot {count} stored facts.",
|
||||
"clearError": "Could not clear the stored facts. Please try again."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Your reminders",
|
||||
"description": "Things you asked the Coach to bring back later. Due ones show up here and on the Coach.",
|
||||
"empty": "No reminders yet. Ask the Coach to remind you about something and it will appear here.",
|
||||
"loadError": "Could not load your reminders.",
|
||||
"dueHeading": "Due now",
|
||||
"allHeading": "Saved",
|
||||
"duePrefix": "due {when}",
|
||||
"confirm": "Keep",
|
||||
"markDone": "Done",
|
||||
"dismiss": "Dismiss",
|
||||
"deleteAria": "Delete this reminder",
|
||||
"note": "Reminders are stored encrypted. Delete one any time — it is removed from what the Coach can see.",
|
||||
"status": {
|
||||
"proposed": "Suggested",
|
||||
"active": "Saved",
|
||||
"due": "Due",
|
||||
"surfaced": "Surfaced",
|
||||
"done": "Done",
|
||||
"dismissed": "Dismissed"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "About me",
|
||||
"description": "Personal context the Coach and the daily briefing read alongside your data.",
|
||||
@@ -4436,7 +4492,24 @@
|
||||
"completeness": "{answered} of {total} answered",
|
||||
"questionsTitle": "The Coach has follow-up questions",
|
||||
"questionsOpenCoach": "Answer them in the Coach"
|
||||
}
|
||||
},
|
||||
"responseTimeoutHeading": "Response timeout",
|
||||
"responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.",
|
||||
"responseTimeoutLabel": "Response timeout (seconds)",
|
||||
"responseTimeoutPlaceholder": "Default (~180)",
|
||||
"responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.",
|
||||
"ocrHeading": "Document scanning (lab reports)",
|
||||
"ocrBody": "Use a dedicated provider for reading uploaded lab reports. When off, scanning uses your main provider.",
|
||||
"ocrEnableLabel": "Enable a dedicated document-scanning provider",
|
||||
"ocrProviderLabel": "Scanning provider",
|
||||
"ocrProviderOption": {
|
||||
"OPENAI": "OpenAI (your API key)",
|
||||
"ANTHROPIC": "Anthropic (Claude)",
|
||||
"LOCAL": "Local model (OpenAI-compatible)"
|
||||
},
|
||||
"ocrModelPlaceholder": "e.g. gpt-4o",
|
||||
"ocrVisionHint": "This model may not be able to read images. Choose a vision-capable model for document scanning.",
|
||||
"ocrKeyLabel": "API key"
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -4663,6 +4736,8 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"identityDescription": "Version, license, and build details for this instance.",
|
||||
"linksDescription": "Source code, changelog, and documentation.",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -6483,13 +6558,13 @@
|
||||
"selectionBarLabel": "Selection actions"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Preventive care",
|
||||
"sectionTitle": "Checkups",
|
||||
"sectionDescription": "Reminders for what to measure or check, when, and where.",
|
||||
"addButton": "Add reminder",
|
||||
"markDone": "Done",
|
||||
"delete": "Delete",
|
||||
"deleteConfirmTitle": "Delete reminder?",
|
||||
"deleteConfirmDescription": "This preventive-care reminder will be permanently deleted.",
|
||||
"deleteConfirmDescription": "This checkup reminder will be permanently deleted.",
|
||||
"disabledBadge": "Off",
|
||||
"nextDue": {
|
||||
"none": "Not scheduled",
|
||||
@@ -6551,7 +6626,7 @@
|
||||
"rruleHint": "RFC-5545, e.g. FREQ=YEARLY for an annual check.",
|
||||
"firstDue": "First due (optional)"
|
||||
},
|
||||
"pushTitle": "Preventive-care reminder",
|
||||
"pushTitle": "Checkup reminder",
|
||||
"pushBody": "Time for: {label}",
|
||||
"pushLocation": "(at {location})",
|
||||
"edit": "Edit",
|
||||
@@ -6562,7 +6637,7 @@
|
||||
"measureNow": "Measure now",
|
||||
"nextDueLabel": "Next due:",
|
||||
"lastDoneLabel": "Last done:",
|
||||
"customize": "Customize preventive care",
|
||||
"customize": "Customize checkups",
|
||||
"adherence": {
|
||||
"label": "Within interval",
|
||||
"onTrack": "On track",
|
||||
@@ -6578,8 +6653,8 @@
|
||||
"empty": "No reminders to customize yet."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "No upcoming preventive-care reminders.",
|
||||
"openLink": "Open preventive care"
|
||||
"empty": "No upcoming checkup reminders.",
|
||||
"openLink": "Open checkups"
|
||||
},
|
||||
"showMeasurements": "Show measurements"
|
||||
},
|
||||
@@ -6720,7 +6795,14 @@
|
||||
"savedToast": "Biomarker saved",
|
||||
"updatedToast": "Biomarker updated",
|
||||
"saveError": "Could not save the biomarker"
|
||||
}
|
||||
},
|
||||
"hide": "Hide",
|
||||
"unhide": "Restore",
|
||||
"hiddenToast": "Biomarker hidden",
|
||||
"unhiddenToast": "Biomarker restored",
|
||||
"hideError": "Could not update the biomarker",
|
||||
"allHidden": "Every biomarker is hidden.",
|
||||
"hiddenSection": "Hidden"
|
||||
},
|
||||
"catalog": {
|
||||
"total-cholesterol": "Total cholesterol",
|
||||
@@ -7041,6 +7123,25 @@
|
||||
"glucoseStructured": "Test your glucose on a structured daily schedule"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Add",
|
||||
"dismiss": "Not now",
|
||||
"applied": "Added",
|
||||
"dismissed": "Dismissed",
|
||||
"failed": "Couldn't add that — please try again",
|
||||
"checkup": {
|
||||
"title": "Add a check-up reminder?"
|
||||
},
|
||||
"note": {
|
||||
"title": "Save a reminder?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Every year",
|
||||
"halfYearly": "Every six months",
|
||||
"quarterly": "Every three months",
|
||||
"monthly": "Every month"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Cadence suggestions",
|
||||
"reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
|
||||
|
||||
+120
-19
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
"vorsorge": "Prevención",
|
||||
"vorsorge": "Revisiones",
|
||||
"illness": "Enfermedad"
|
||||
},
|
||||
"auth": {
|
||||
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Página anterior",
|
||||
"nextPage": "Página siguiente",
|
||||
"dailyTotalCaption": "({count} muestras)",
|
||||
"openMetricInsights": "Abrir análisis de {type}",
|
||||
"sleepNapCaption": "+{count} siesta(s)",
|
||||
"sleepAwakeningsCaption": "{count} despertar(es)",
|
||||
"expandDay": "Mostrar las muestras de este día",
|
||||
@@ -1945,7 +1946,6 @@
|
||||
"confidenceMittel": "Media",
|
||||
"confidenceGering": "Baja",
|
||||
"loadError": "No se han podido cargar los análisis",
|
||||
"heroGenerated": "Generado {time}",
|
||||
"heroPersonalBaseline": "Basado en tus últimos 90 días",
|
||||
"heroRegenerate": "Regenerar",
|
||||
"heroRegenerating": "Generando",
|
||||
@@ -1957,7 +1957,6 @@
|
||||
"narrativeMonth": "Este mes",
|
||||
"narrativePreparing": "Preparando tu resumen…",
|
||||
"narrativeUpdating": "Actualizando…",
|
||||
"narrativeUpdated": "Actualizado {time}",
|
||||
"narrativeProvenanceLabel": "Cómo se elaboró esto",
|
||||
"narrativeProvenanceMethod": "Un resumen del período en lenguaje sencillo, basado únicamente en tus valores registrados y en las asociaciones que superaron el control estadístico de comparaciones múltiples. Descriptivo, nunca una causa.",
|
||||
"narrativeProvenanceMetrics": "Basado en: {metrics}",
|
||||
@@ -2098,6 +2097,9 @@
|
||||
"composerPlaceholder": "Pregúntame sobre tus datos…",
|
||||
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
|
||||
"fabLabel": "Abrir el coach",
|
||||
"readAloud": "Leer en voz alta",
|
||||
"readAloudStop": "Detener lectura",
|
||||
"messageTimeLabel": "Mensaje enviado {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "¿Añadir esta respuesta a tu información personal?",
|
||||
"confirm": "Añadir",
|
||||
@@ -2211,6 +2213,8 @@
|
||||
"settingsSourcesPointerLink": "Abrir el coach",
|
||||
"settingsExcludeLabel": "Excluir métricas",
|
||||
"settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.",
|
||||
"settingsContextLabel": "Enviar contexto",
|
||||
"settingsContextHint": "Bloques opcionales en los que el coach puede apoyarse. Si desactivas alguno, se omitirá en cada respuesta.",
|
||||
"settingsEvidenceLabel": "Mostrar evidencia por defecto",
|
||||
"settingsEvidenceHint": "Abre automáticamente el cuadro de evidencias bajo cada respuesta.",
|
||||
"settingsDefaultWindowLabel": "Periodo de análisis por defecto",
|
||||
@@ -2226,8 +2230,6 @@
|
||||
"dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).",
|
||||
"providerRateLimitTitle": "Proveedor con límite de tasa",
|
||||
"providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.",
|
||||
"settingsContextLabel": "Enviar contexto",
|
||||
"settingsContextHint": "Bloques opcionales en los que el coach puede apoyarse. Si desactivas alguno, se omitirá en cada respuesta.",
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Cardiovascular",
|
||||
@@ -2320,6 +2322,33 @@
|
||||
"groupYesterday": "Ayer",
|
||||
"groupThisWeek": "Esta semana",
|
||||
"groupEarlier": "Anteriores"
|
||||
},
|
||||
"seed": {
|
||||
"bloodPressure": "Repasa conmigo la tendencia de mi presión arterial de los últimos 30 días — ¿hay algo que deba vigilar?",
|
||||
"weight": "¿Cómo ha evolucionado mi peso últimamente y la dirección es algo sobre lo que actuar?",
|
||||
"bmi": "¿Qué me dice mi tendencia de IMC y cómo debería interpretarla?",
|
||||
"pulse": "Repasa conmigo mis lecturas de pulso — ¿hay algo fuera de lo normal?",
|
||||
"restingHr": "¿Cómo ha evolucionado mi frecuencia cardíaca en reposo y qué dice sobre mi forma física?",
|
||||
"hrv": "¿Qué me dice mi variabilidad de la frecuencia cardíaca sobre la recuperación y el estrés últimamente?",
|
||||
"sleep": "Repasa conmigo mi sueño reciente — duración, regularidad y cualquier cosa que valga la pena cambiar.",
|
||||
"mood": "¿Qué patrones ves en mi estado de ánimo durante las últimas semanas?",
|
||||
"medications": "¿Cómo va mi adherencia a la medicación y coincide con cómo me he sentido?",
|
||||
"steps": "¿Qué tan activo he estado últimamente según mis pasos y cómo se compara con un nivel de referencia saludable?",
|
||||
"activeEnergy": "¿Qué dice mi tendencia de energía activa sobre mi nivel de actividad últimamente?",
|
||||
"cardioFitness": "¿Qué significa la tendencia de mi capacidad cardiorrespiratoria (VO₂ máx.) y cómo la llevo en la dirección correcta?",
|
||||
"bloodGlucose": "Repasa conmigo mis últimas lecturas de glucosa — ¿están en un rango saludable?",
|
||||
"oxygenSaturation": "¿Qué me dice mi tendencia de oxígeno en sangre (SpO₂)?",
|
||||
"respiratoryRate": "¿Qué dice mi tendencia de frecuencia respiratoria sobre mi salud?",
|
||||
"workouts": "Repasa conmigo mis entrenamientos recientes — carga, frecuencia y cómo me recupero entre ellos.",
|
||||
"recoveryPage": "¿Por qué mi recuperación está donde está ahora mismo y qué la está provocando?",
|
||||
"briefing": "Repasa conmigo el resumen de hoy — ¿en qué debería centrarme y hay algo en lo que valga la pena actuar?",
|
||||
"healthScore": "¿Por qué mi puntuación de salud es {score} de 100 y qué la mejoraría más?",
|
||||
"periodWeek": "Repasa conmigo cómo fue mi última semana en mis métricas de salud — ¿qué destaca?",
|
||||
"periodMonth": "Repasa conmigo cómo fue mi último mes en mis métricas de salud — ¿qué destaca?",
|
||||
"correlationBpCompliance": "¿Cómo se relaciona mi adherencia a la medicación con mi presión arterial? Explícame qué significa esta correlación para mí.",
|
||||
"correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.",
|
||||
"correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?",
|
||||
"recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
@@ -3240,6 +3269,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Tus constantes",
|
||||
"sectionSubtitle": "Señales cardiovasculares y respiratorias en reposo frente a tu propia línea base",
|
||||
"loadingLabel": "Cargando tus constantes",
|
||||
"loadError": "No se pudieron cargar tus constantes en este momento.",
|
||||
"building": "Creando tu rango habitual — {count} de {target} días",
|
||||
@@ -3366,6 +3396,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Tus indicadores de salud",
|
||||
"sectionSubtitle": "Valores combinados de recuperación, estrés y esfuerzo leídos de tus dispositivos conectados",
|
||||
"loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.",
|
||||
"recovery": "Recuperación",
|
||||
"stress": "Estrés",
|
||||
@@ -3611,7 +3642,10 @@
|
||||
"returnLabel": "Tu {metric} ha vuelto a tu rango habitual tras {count} días — fuera lo que fuera, ya pasó.",
|
||||
"daysInside": "{count} días dentro",
|
||||
"daysOutside": "{count} días fuera"
|
||||
}
|
||||
},
|
||||
"updatedTodayAt": "Actualizado hoy, {time}",
|
||||
"updatedYesterday": "Actualizado ayer",
|
||||
"updatedOn": "Actualizado el {date}"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Por defecto",
|
||||
@@ -3677,8 +3711,8 @@
|
||||
"body": "El diario de enfermedades registra episodios agudos y diagnósticos crónicos con sus brotes. Oculto: correlacionar un episodio con tus mediciones y el Modo descanso que pausa los recordatorios. Así el Coach entiende por qué los valores varían un tiempo."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prevención",
|
||||
"body": "Prevención te recuerda revisiones y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico."
|
||||
"title": "Revisiones",
|
||||
"body": "Las revisiones te recuerdan controles y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
@@ -3702,7 +3736,7 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "Informe médico",
|
||||
"body": "El informe médico reúne mediciones, medicación, valores de laboratorio y prevención en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados."
|
||||
"body": "El informe médico reúne mediciones, medicación, valores de laboratorio y revisiones en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Logros",
|
||||
@@ -4132,8 +4166,8 @@
|
||||
"subtitle": "Gestiona la vista y el orden de los episodios."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prevención",
|
||||
"description": "Vista, orden y recordatorios de prevención individuales.",
|
||||
"title": "Revisiones",
|
||||
"description": "Vista, orden y recordatorios de revisión individuales.",
|
||||
"subtitle": "Gestiona la vista, el orden y los recordatorios individuales."
|
||||
},
|
||||
"modules": {
|
||||
@@ -4413,6 +4447,28 @@
|
||||
"clearedToast": "Se olvidaron {count} datos guardados.",
|
||||
"clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Tus recordatorios",
|
||||
"description": "Cosas que le pediste al Coach que retomara más tarde. Las que vencen aparecen aquí y en el Coach.",
|
||||
"empty": "Aún no hay recordatorios. Pídele al Coach que te recuerde algo y aparecerá aquí.",
|
||||
"loadError": "No se pudieron cargar tus recordatorios.",
|
||||
"dueHeading": "Vencen ahora",
|
||||
"allHeading": "Guardados",
|
||||
"duePrefix": "vence {when}",
|
||||
"confirm": "Conservar",
|
||||
"markDone": "Hecho",
|
||||
"dismiss": "Descartar",
|
||||
"deleteAria": "Eliminar este recordatorio",
|
||||
"note": "Los recordatorios se guardan cifrados. Elimina uno cuando quieras — desaparece de lo que el Coach puede ver.",
|
||||
"status": {
|
||||
"proposed": "Sugerido",
|
||||
"active": "Guardado",
|
||||
"due": "Vencido",
|
||||
"surfaced": "Mostrado",
|
||||
"done": "Hecho",
|
||||
"dismissed": "Descartado"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Sobre mí",
|
||||
"description": "Contexto personal que el Coach y el informe diario leen junto con tus datos.",
|
||||
@@ -4436,7 +4492,24 @@
|
||||
"completeness": "{answered} de {total} respondidas",
|
||||
"questionsTitle": "El Coach tiene preguntas",
|
||||
"questionsOpenCoach": "Responderlas en el Coach"
|
||||
}
|
||||
},
|
||||
"responseTimeoutHeading": "Tiempo de espera de la respuesta",
|
||||
"responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.",
|
||||
"responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)",
|
||||
"responseTimeoutPlaceholder": "Predeterminado (~180)",
|
||||
"responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.",
|
||||
"ocrHeading": "Escaneo de documentos (informes de laboratorio)",
|
||||
"ocrBody": "Usa un proveedor dedicado para leer los informes de laboratorio subidos. Si está desactivado, el escaneo usa tu proveedor principal.",
|
||||
"ocrEnableLabel": "Activar un proveedor dedicado para el escaneo de documentos",
|
||||
"ocrProviderLabel": "Proveedor de escaneo",
|
||||
"ocrProviderOption": {
|
||||
"OPENAI": "OpenAI (tu clave de API)",
|
||||
"ANTHROPIC": "Anthropic (Claude)",
|
||||
"LOCAL": "Modelo local (compatible con OpenAI)"
|
||||
},
|
||||
"ocrModelPlaceholder": "p. ej. gpt-4o",
|
||||
"ocrVisionHint": "Puede que este modelo no pueda leer imágenes. Elige un modelo con capacidad de visión para el escaneo de documentos.",
|
||||
"ocrKeyLabel": "Clave de API"
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -4663,6 +4736,8 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentación",
|
||||
"linksHeading": "Fuentes y documentación",
|
||||
"identityDescription": "Versión, licencia y detalles de compilación de esta instancia.",
|
||||
"linksDescription": "Código fuente, registro de cambios y documentación.",
|
||||
"newerAvailable": "Nueva versión: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -6483,13 +6558,13 @@
|
||||
"selectionBarLabel": "Acciones de selección"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Prevención",
|
||||
"sectionTitle": "Revisiones",
|
||||
"sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.",
|
||||
"addButton": "Añadir recordatorio",
|
||||
"markDone": "Hecho",
|
||||
"delete": "Eliminar",
|
||||
"deleteConfirmTitle": "¿Eliminar recordatorio?",
|
||||
"deleteConfirmDescription": "Este recordatorio de prevención se eliminará de forma permanente.",
|
||||
"deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.",
|
||||
"disabledBadge": "Apagado",
|
||||
"nextDue": {
|
||||
"none": "Sin programar",
|
||||
@@ -6551,7 +6626,7 @@
|
||||
"rruleHint": "RFC-5545, p. ej. FREQ=YEARLY para una revisión anual.",
|
||||
"firstDue": "Primer vencimiento (opcional)"
|
||||
},
|
||||
"pushTitle": "Recordatorio de prevención",
|
||||
"pushTitle": "Recordatorio de revisión",
|
||||
"pushBody": "Toca: {label}",
|
||||
"pushLocation": "(en {location})",
|
||||
"edit": "Editar",
|
||||
@@ -6562,7 +6637,7 @@
|
||||
"measureNow": "Medir ahora",
|
||||
"nextDueLabel": "Próximo vencimiento:",
|
||||
"lastDoneLabel": "Última vez:",
|
||||
"customize": "Personalizar prevención",
|
||||
"customize": "Personalizar revisiones",
|
||||
"adherence": {
|
||||
"label": "Dentro del intervalo",
|
||||
"onTrack": "Al día",
|
||||
@@ -6578,8 +6653,8 @@
|
||||
"empty": "Aún no hay recordatorios para personalizar."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "No hay recordatorios de prevención próximos.",
|
||||
"openLink": "Abrir prevención"
|
||||
"empty": "No hay revisiones próximas.",
|
||||
"openLink": "Abrir revisiones"
|
||||
},
|
||||
"showMeasurements": "Ver mediciones"
|
||||
},
|
||||
@@ -6720,7 +6795,14 @@
|
||||
"savedToast": "Biomarcador guardado",
|
||||
"updatedToast": "Biomarcador actualizado",
|
||||
"saveError": "No se pudo guardar el biomarcador"
|
||||
}
|
||||
},
|
||||
"hide": "Ocultar",
|
||||
"unhide": "Restaurar",
|
||||
"hiddenToast": "Biomarcador ocultado",
|
||||
"unhiddenToast": "Biomarcador restaurado",
|
||||
"hideError": "No se pudo actualizar el biomarcador",
|
||||
"allHidden": "Todos los biomarcadores están ocultos.",
|
||||
"hiddenSection": "Ocultos"
|
||||
},
|
||||
"catalog": {
|
||||
"total-cholesterol": "Colesterol total",
|
||||
@@ -7041,6 +7123,25 @@
|
||||
"glucoseStructured": "Mide tu glucosa con un horario diario estructurado"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Añadir",
|
||||
"dismiss": "Ahora no",
|
||||
"applied": "Añadido",
|
||||
"dismissed": "Descartado",
|
||||
"failed": "No se pudo añadir — inténtalo de nuevo",
|
||||
"checkup": {
|
||||
"title": "¿Añadir un recordatorio de revisión?"
|
||||
},
|
||||
"note": {
|
||||
"title": "¿Guardar un recordatorio?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Cada año",
|
||||
"halfYearly": "Cada seis meses",
|
||||
"quarterly": "Cada tres meses",
|
||||
"monthly": "Cada mes"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Sugerencias de frecuencia",
|
||||
"reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad."
|
||||
|
||||
+120
-19
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
"vorsorge": "Prévention",
|
||||
"vorsorge": "Examens",
|
||||
"illness": "Maladie"
|
||||
},
|
||||
"auth": {
|
||||
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Page précédente",
|
||||
"nextPage": "Page suivante",
|
||||
"dailyTotalCaption": "({count} échantillons)",
|
||||
"openMetricInsights": "Ouvrir l'analyse {type}",
|
||||
"sleepNapCaption": "+{count} sieste(s)",
|
||||
"sleepAwakeningsCaption": "{count} réveil(s)",
|
||||
"expandDay": "Afficher les échantillons de ce jour",
|
||||
@@ -1945,7 +1946,6 @@
|
||||
"confidenceMittel": "Moyenne",
|
||||
"confidenceGering": "Faible",
|
||||
"loadError": "Impossible de charger les analyses",
|
||||
"heroGenerated": "Généré {time}",
|
||||
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
|
||||
"heroRegenerate": "Régénérer",
|
||||
"heroRegenerating": "Génération en cours",
|
||||
@@ -1957,7 +1957,6 @@
|
||||
"narrativeMonth": "Ce mois-ci",
|
||||
"narrativePreparing": "Préparation de votre résumé…",
|
||||
"narrativeUpdating": "Mise à jour…",
|
||||
"narrativeUpdated": "Mis à jour {time}",
|
||||
"narrativeProvenanceLabel": "Comment cela a été établi",
|
||||
"narrativeProvenanceMethod": "Un résumé de la période en langage clair, établi uniquement à partir de vos valeurs enregistrées et des associations ayant survécu au contrôle statistique des comparaisons multiples. Descriptif, jamais une cause.",
|
||||
"narrativeProvenanceMetrics": "Basé sur : {metrics}",
|
||||
@@ -2098,6 +2097,9 @@
|
||||
"composerPlaceholder": "Pose-moi une question sur tes données…",
|
||||
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
|
||||
"fabLabel": "Ouvrir le coach",
|
||||
"readAloud": "Lire à voix haute",
|
||||
"readAloudStop": "Arrêter la lecture",
|
||||
"messageTimeLabel": "Message envoyé {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Ajouter cette réponse à vos informations personnelles ?",
|
||||
"confirm": "Ajouter",
|
||||
@@ -2211,6 +2213,8 @@
|
||||
"settingsSourcesPointerLink": "Ouvrir le 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",
|
||||
"settingsContextHint": "Blocs optionnels sur lesquels le coach peut s’appuyer. Si tu en désactives un, il sera omis dans chaque réponse.",
|
||||
"settingsEvidenceLabel": "Afficher les preuves par défaut",
|
||||
"settingsEvidenceHint": "Ouvre automatiquement l’encadré preuves sous chaque réponse.",
|
||||
"settingsDefaultWindowLabel": "Période d’analyse par défaut",
|
||||
@@ -2226,8 +2230,6 @@
|
||||
"dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).",
|
||||
"providerRateLimitTitle": "Fournisseur limité en débit",
|
||||
"providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.",
|
||||
"settingsContextLabel": "Envoyer le contexte",
|
||||
"settingsContextHint": "Blocs optionnels sur lesquels le coach peut s’appuyer. Si tu en désactives un, il sera omis dans chaque réponse.",
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Cardiovasculaire",
|
||||
@@ -2320,6 +2322,33 @@
|
||||
"groupYesterday": "Hier",
|
||||
"groupThisWeek": "Cette semaine",
|
||||
"groupEarlier": "Plus tôt"
|
||||
},
|
||||
"seed": {
|
||||
"bloodPressure": "Passe en revue avec moi la tendance de ma tension artérielle sur les 30 derniers jours — y a-t-il quelque chose à surveiller ?",
|
||||
"weight": "Comment mon poids a-t-il évolué ces derniers temps, et la tendance mérite-t-elle d'agir ?",
|
||||
"bmi": "Que m'indique la tendance de mon IMC, et comment dois-je l'interpréter ?",
|
||||
"pulse": "Passe en revue avec moi mes mesures de pouls — y a-t-il quelque chose d'inhabituel ?",
|
||||
"restingHr": "Comment ma fréquence cardiaque au repos a-t-elle évolué, et que dit-elle de ma condition physique ?",
|
||||
"hrv": "Que m'indique ma variabilité de la fréquence cardiaque sur ma récupération et mon stress ces derniers temps ?",
|
||||
"sleep": "Passe en revue avec moi mon sommeil récent — durée, régularité et tout ce qui mériterait d'être changé.",
|
||||
"mood": "Quels schémas observes-tu dans mon humeur ces dernières semaines ?",
|
||||
"medications": "Où en est mon observance médicamenteuse, et correspond-elle à la façon dont je me sens ?",
|
||||
"steps": "À quel point ai-je été actif ces derniers temps d'après mes pas, et comment cela se compare-t-il à un niveau de référence sain ?",
|
||||
"activeEnergy": "Que dit ma tendance de dépense énergétique active sur mon niveau d'activité ces derniers temps ?",
|
||||
"cardioFitness": "Que signifie la tendance de ma condition cardio (VO₂ max), et comment la faire évoluer dans le bon sens ?",
|
||||
"bloodGlucose": "Passe en revue avec moi mes dernières mesures de glycémie — sont-elles dans une fourchette saine ?",
|
||||
"oxygenSaturation": "Que m'indique ma tendance de saturation en oxygène (SpO₂) ?",
|
||||
"respiratoryRate": "Que dit ma tendance de fréquence respiratoire sur ma santé ?",
|
||||
"workouts": "Passe en revue avec moi mes séances d'entraînement récentes — charge, fréquence et comment je récupère entre elles.",
|
||||
"recoveryPage": "Pourquoi ma récupération en est-elle là en ce moment, et qu'est-ce qui l'explique ?",
|
||||
"briefing": "Passe en revue avec moi le briefing d'aujourd'hui — sur quoi devrais-je me concentrer, et y a-t-il quelque chose à entreprendre ?",
|
||||
"healthScore": "Pourquoi mon score de santé est-il de {score} sur 100, et qu'est-ce qui le ferait le plus progresser ?",
|
||||
"periodWeek": "Passe en revue avec moi comment s'est déroulée ma dernière semaine sur mes indicateurs de santé — qu'est-ce qui ressort ?",
|
||||
"periodMonth": "Passe en revue avec moi comment s'est déroulé mon dernier mois sur mes indicateurs de santé — qu'est-ce qui ressort ?",
|
||||
"correlationBpCompliance": "Quel est le lien entre mon observance médicamenteuse et ma tension artérielle ? Explique-moi ce que cette corrélation signifie pour moi.",
|
||||
"correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.",
|
||||
"correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?",
|
||||
"recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
@@ -3240,6 +3269,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Vos constantes",
|
||||
"sectionSubtitle": "Signaux cardiovasculaires et respiratoires au repos par rapport à ta propre référence",
|
||||
"loadingLabel": "Chargement de vos constantes",
|
||||
"loadError": "Impossible de charger vos constantes pour le moment.",
|
||||
"building": "Construction de votre plage habituelle — {count} sur {target} jours",
|
||||
@@ -3366,6 +3396,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Tes indicateurs de santé",
|
||||
"sectionSubtitle": "Valeurs combinées de récupération, de stress et d'effort issues de tes appareils connectés",
|
||||
"loadError": "Impossible de charger vos scores de bien-être pour le moment.",
|
||||
"recovery": "Récupération",
|
||||
"stress": "Stress",
|
||||
@@ -3611,7 +3642,10 @@
|
||||
"returnLabel": "Ton {metric} est de nouveau dans ta plage habituelle après {count} jours — quoi que c'était, c'est passé.",
|
||||
"daysInside": "{count} jours dans la plage",
|
||||
"daysOutside": "{count} jours hors plage"
|
||||
}
|
||||
},
|
||||
"updatedTodayAt": "Mis à jour aujourd'hui, {time}",
|
||||
"updatedYesterday": "Mis à jour hier",
|
||||
"updatedOn": "Mis à jour le {date}"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Par défaut",
|
||||
@@ -3677,8 +3711,8 @@
|
||||
"body": "Le journal des maladies consigne les épisodes aigus et les diagnostics chroniques avec leurs poussées. Caché : corréler un épisode à vos mesures et le Mode repos qui suspend les rappels. Le Coach comprend ainsi pourquoi les valeurs dérivent un temps."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prévention",
|
||||
"body": "Prévention vous rappelle examens et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical."
|
||||
"title": "Examens",
|
||||
"body": "Les examens vous rappellent dépistages et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
@@ -3702,7 +3736,7 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "Rapport médical",
|
||||
"body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et prévention en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues."
|
||||
"body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et examens en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Réussites",
|
||||
@@ -4132,8 +4166,8 @@
|
||||
"subtitle": "Gérez l’affichage et l’ordre des épisodes."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prévention",
|
||||
"description": "Affichage, ordre et rappels de prévention individuels.",
|
||||
"title": "Examens",
|
||||
"description": "Affichage, ordre et rappels d’examen individuels.",
|
||||
"subtitle": "Gérez l’affichage, l’ordre et les rappels individuels."
|
||||
},
|
||||
"modules": {
|
||||
@@ -4413,6 +4447,28 @@
|
||||
"clearedToast": "{count} informations enregistrées oubliées.",
|
||||
"clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Tes rappels",
|
||||
"description": "Les choses que tu as demandé au Coach de te rappeler plus tard. Celles qui arrivent à échéance apparaissent ici et chez le Coach.",
|
||||
"empty": "Aucun rappel pour l'instant. Demande au Coach de te rappeler quelque chose et cela apparaîtra ici.",
|
||||
"loadError": "Impossible de charger tes rappels.",
|
||||
"dueHeading": "À échéance",
|
||||
"allHeading": "Enregistrés",
|
||||
"duePrefix": "à échéance {when}",
|
||||
"confirm": "Garder",
|
||||
"markDone": "Terminé",
|
||||
"dismiss": "Ignorer",
|
||||
"deleteAria": "Supprimer ce rappel",
|
||||
"note": "Les rappels sont stockés chiffrés. Supprime-en un à tout moment — il disparaît de ce que le Coach peut voir.",
|
||||
"status": {
|
||||
"proposed": "Suggéré",
|
||||
"active": "Enregistré",
|
||||
"due": "À échéance",
|
||||
"surfaced": "Affiché",
|
||||
"done": "Terminé",
|
||||
"dismissed": "Ignoré"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "À propos de moi",
|
||||
"description": "Contexte personnel que le Coach et le briefing quotidien lisent en plus de vos données.",
|
||||
@@ -4436,7 +4492,24 @@
|
||||
"completeness": "{answered} sur {total} renseignés",
|
||||
"questionsTitle": "Le Coach a des questions",
|
||||
"questionsOpenCoach": "Y répondre dans le Coach"
|
||||
}
|
||||
},
|
||||
"responseTimeoutHeading": "Délai de réponse",
|
||||
"responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.",
|
||||
"responseTimeoutLabel": "Délai de réponse (secondes)",
|
||||
"responseTimeoutPlaceholder": "Par défaut (~180)",
|
||||
"responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.",
|
||||
"ocrHeading": "Numérisation de documents (comptes rendus de laboratoire)",
|
||||
"ocrBody": "Utilisez un fournisseur dédié pour lire les comptes rendus de laboratoire importés. Désactivé, la numérisation utilise votre fournisseur principal.",
|
||||
"ocrEnableLabel": "Activer un fournisseur dédié à la numérisation de documents",
|
||||
"ocrProviderLabel": "Fournisseur de numérisation",
|
||||
"ocrProviderOption": {
|
||||
"OPENAI": "OpenAI (votre clé API)",
|
||||
"ANTHROPIC": "Anthropic (Claude)",
|
||||
"LOCAL": "Modèle local (compatible OpenAI)"
|
||||
},
|
||||
"ocrModelPlaceholder": "p. ex. gpt-4o",
|
||||
"ocrVisionHint": "Ce modèle ne peut peut-être pas lire les images. Choisissez un modèle capable de vision pour la numérisation de documents.",
|
||||
"ocrKeyLabel": "Clé API"
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -4663,6 +4736,8 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources et documentation",
|
||||
"identityDescription": "Version, licence et détails de build de cette instance.",
|
||||
"linksDescription": "Code source, journal des modifications et documentation.",
|
||||
"newerAvailable": "Nouvelle version : {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -6483,13 +6558,13 @@
|
||||
"selectionBarLabel": "Actions de sélection"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Prévention",
|
||||
"sectionTitle": "Examens",
|
||||
"sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.",
|
||||
"addButton": "Ajouter un rappel",
|
||||
"markDone": "Terminé",
|
||||
"delete": "Supprimer",
|
||||
"deleteConfirmTitle": "Supprimer le rappel ?",
|
||||
"deleteConfirmDescription": "Ce rappel de prévention sera définitivement supprimé.",
|
||||
"deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.",
|
||||
"disabledBadge": "Désactivé",
|
||||
"nextDue": {
|
||||
"none": "Non planifié",
|
||||
@@ -6551,7 +6626,7 @@
|
||||
"rruleHint": "RFC-5545, p. ex. FREQ=YEARLY pour un contrôle annuel.",
|
||||
"firstDue": "Première échéance (facultatif)"
|
||||
},
|
||||
"pushTitle": "Rappel de prévention",
|
||||
"pushTitle": "Rappel d’examen",
|
||||
"pushBody": "C'est l'heure : {label}",
|
||||
"pushLocation": "(chez {location})",
|
||||
"edit": "Modifier",
|
||||
@@ -6562,7 +6637,7 @@
|
||||
"measureNow": "Mesurer maintenant",
|
||||
"nextDueLabel": "Prochaine échéance :",
|
||||
"lastDoneLabel": "Dernière fois :",
|
||||
"customize": "Personnaliser la prévention",
|
||||
"customize": "Personnaliser les examens",
|
||||
"adherence": {
|
||||
"label": "Dans l'intervalle",
|
||||
"onTrack": "Dans les temps",
|
||||
@@ -6578,8 +6653,8 @@
|
||||
"empty": "Aucun rappel à personnaliser pour l'instant."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "Aucun rappel de prévention à venir.",
|
||||
"openLink": "Ouvrir la prévention"
|
||||
"empty": "Aucun rappel d’examen à venir.",
|
||||
"openLink": "Ouvrir les examens"
|
||||
},
|
||||
"showMeasurements": "Afficher les mesures"
|
||||
},
|
||||
@@ -6720,7 +6795,14 @@
|
||||
"savedToast": "Biomarqueur enregistré",
|
||||
"updatedToast": "Biomarqueur mis à jour",
|
||||
"saveError": "Impossible d'enregistrer le biomarqueur"
|
||||
}
|
||||
},
|
||||
"hide": "Masquer",
|
||||
"unhide": "Restaurer",
|
||||
"hiddenToast": "Biomarqueur masqué",
|
||||
"unhiddenToast": "Biomarqueur restauré",
|
||||
"hideError": "Impossible de mettre à jour le biomarqueur",
|
||||
"allHidden": "Tous les biomarqueurs sont masqués.",
|
||||
"hiddenSection": "Masqués"
|
||||
},
|
||||
"catalog": {
|
||||
"total-cholesterol": "Cholestérol total",
|
||||
@@ -7041,6 +7123,25 @@
|
||||
"glucoseStructured": "Mesurez votre glycémie selon un horaire quotidien structuré"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Ajouter",
|
||||
"dismiss": "Pas maintenant",
|
||||
"applied": "Ajouté",
|
||||
"dismissed": "Ignoré",
|
||||
"failed": "Impossible d'ajouter — réessaie",
|
||||
"checkup": {
|
||||
"title": "Ajouter un rappel d'examen ?"
|
||||
},
|
||||
"note": {
|
||||
"title": "Enregistrer un rappel ?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Tous les ans",
|
||||
"halfYearly": "Tous les six mois",
|
||||
"quarterly": "Tous les trois mois",
|
||||
"monthly": "Tous les mois"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Suggestions de fréquence",
|
||||
"reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement."
|
||||
|
||||
+120
-19
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
"vorsorge": "Prevenzione",
|
||||
"vorsorge": "Controlli",
|
||||
"illness": "Malattia"
|
||||
},
|
||||
"auth": {
|
||||
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Pagina precedente",
|
||||
"nextPage": "Pagina successiva",
|
||||
"dailyTotalCaption": "({count} campioni)",
|
||||
"openMetricInsights": "Apri l'analisi {type}",
|
||||
"sleepNapCaption": "+{count} pisolino/i",
|
||||
"sleepAwakeningsCaption": "{count} risveglio/i",
|
||||
"expandDay": "Mostra i campioni di questa giornata",
|
||||
@@ -1945,7 +1946,6 @@
|
||||
"confidenceMittel": "Media",
|
||||
"confidenceGering": "Bassa",
|
||||
"loadError": "Impossibile caricare le analisi",
|
||||
"heroGenerated": "Generato {time}",
|
||||
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
|
||||
"heroRegenerate": "Rigenera",
|
||||
"heroRegenerating": "Generazione in corso",
|
||||
@@ -1957,7 +1957,6 @@
|
||||
"narrativeMonth": "Questo mese",
|
||||
"narrativePreparing": "Preparazione del riepilogo…",
|
||||
"narrativeUpdating": "Aggiornamento…",
|
||||
"narrativeUpdated": "Aggiornato {time}",
|
||||
"narrativeProvenanceLabel": "Come è stato realizzato",
|
||||
"narrativeProvenanceMethod": "Un riepilogo del periodo in linguaggio semplice, basato esclusivamente sui valori registrati e sulle associazioni che hanno superato il controllo statistico dei confronti multipli. Descrittivo, mai una causa.",
|
||||
"narrativeProvenanceMetrics": "Basato su: {metrics}",
|
||||
@@ -2098,6 +2097,9 @@
|
||||
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
|
||||
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
|
||||
"fabLabel": "Apri il coach",
|
||||
"readAloud": "Leggi ad alta voce",
|
||||
"readAloudStop": "Interrompi lettura",
|
||||
"messageTimeLabel": "Messaggio inviato {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Aggiungere questa risposta alle tue informazioni personali?",
|
||||
"confirm": "Aggiungi",
|
||||
@@ -2211,6 +2213,8 @@
|
||||
"settingsSourcesPointerLink": "Apri il coach",
|
||||
"settingsExcludeLabel": "Escludi metriche",
|
||||
"settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.",
|
||||
"settingsContextLabel": "Invia contesto",
|
||||
"settingsContextHint": "Blocchi opzionali su cui il coach può basarsi. Disattivandone uno, viene omesso da ogni risposta.",
|
||||
"settingsEvidenceLabel": "Mostra le evidenze per default",
|
||||
"settingsEvidenceHint": "Apre automaticamente il riquadro delle evidenze sotto ogni risposta.",
|
||||
"settingsDefaultWindowLabel": "Periodo di analisi predefinito",
|
||||
@@ -2226,8 +2230,6 @@
|
||||
"dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).",
|
||||
"providerRateLimitTitle": "Provider con rate-limit",
|
||||
"providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.",
|
||||
"settingsContextLabel": "Invia contesto",
|
||||
"settingsContextHint": "Blocchi opzionali su cui il coach può basarsi. Disattivandone uno, viene omesso da ogni risposta.",
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Cardiovascolare",
|
||||
@@ -2320,6 +2322,33 @@
|
||||
"groupYesterday": "Ieri",
|
||||
"groupThisWeek": "Questa settimana",
|
||||
"groupEarlier": "Precedenti"
|
||||
},
|
||||
"seed": {
|
||||
"bloodPressure": "Esamina con me l'andamento della mia pressione arteriosa negli ultimi 30 giorni — c'è qualcosa che dovrei tenere d'occhio?",
|
||||
"weight": "Come si è evoluto il mio peso ultimamente e la direzione è qualcosa su cui intervenire?",
|
||||
"bmi": "Cosa mi dice l'andamento del mio BMI e come dovrei interpretarlo?",
|
||||
"pulse": "Esamina con me le mie letture del battito — c'è qualcosa fuori dal comune?",
|
||||
"restingHr": "Come si è evoluta la mia frequenza cardiaca a riposo e cosa dice sulla mia forma fisica?",
|
||||
"hrv": "Cosa mi dice la mia variabilità della frequenza cardiaca su recupero e stress ultimamente?",
|
||||
"sleep": "Esamina con me il mio sonno recente — durata, regolarità e qualsiasi cosa valga la pena cambiare.",
|
||||
"mood": "Quali schemi noti nel mio umore nelle ultime settimane?",
|
||||
"medications": "Com'è la mia aderenza alla terapia e corrisponde a come mi sono sentito?",
|
||||
"steps": "Quanto sono stato attivo ultimamente in base ai miei passi e come si confronta con un valore di riferimento sano?",
|
||||
"activeEnergy": "Cosa dice il mio andamento dell'energia attiva sul mio livello di attività ultimamente?",
|
||||
"cardioFitness": "Cosa significa l'andamento della mia forma cardiorespiratoria (VO₂ max) e come la indirizzo nella direzione giusta?",
|
||||
"bloodGlucose": "Esamina con me le mie ultime letture di glicemia — sono in un intervallo sano?",
|
||||
"oxygenSaturation": "Cosa mi dice l'andamento della mia saturazione di ossigeno (SpO₂)?",
|
||||
"respiratoryRate": "Cosa dice il mio andamento della frequenza respiratoria sulla mia salute?",
|
||||
"workouts": "Esamina con me i miei allenamenti recenti — carico, frequenza e come recupero tra uno e l'altro.",
|
||||
"recoveryPage": "Perché il mio recupero è dov'è in questo momento e cosa lo determina?",
|
||||
"briefing": "Esamina con me il riepilogo di oggi — su cosa dovrei concentrarmi e c'è qualcosa su cui vale la pena agire?",
|
||||
"healthScore": "Perché il mio punteggio di salute è {score} su 100 e cosa lo migliorerebbe di più?",
|
||||
"periodWeek": "Esamina con me com'è andata la mia ultima settimana sui miei parametri di salute — cosa spicca?",
|
||||
"periodMonth": "Esamina con me com'è andato il mio ultimo mese sui miei parametri di salute — cosa spicca?",
|
||||
"correlationBpCompliance": "Che relazione c'è tra la mia aderenza alla terapia e la mia pressione arteriosa? Spiegami cosa significa per me questa correlazione.",
|
||||
"correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.",
|
||||
"correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?",
|
||||
"recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
@@ -3240,6 +3269,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "I tuoi parametri vitali",
|
||||
"sectionSubtitle": "Segnali cardiovascolari e respiratori a riposo rispetto alla tua baseline personale",
|
||||
"loadingLabel": "Caricamento dei tuoi parametri vitali",
|
||||
"loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.",
|
||||
"building": "Creazione del tuo intervallo abituale — {count} di {target} giorni",
|
||||
@@ -3366,6 +3396,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "I tuoi indicatori di salute",
|
||||
"sectionSubtitle": "Valori combinati di recupero, stress e sforzo letti dai tuoi dispositivi connessi",
|
||||
"loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.",
|
||||
"recovery": "Recupero",
|
||||
"stress": "Stress",
|
||||
@@ -3611,7 +3642,10 @@
|
||||
"returnLabel": "Il tuo {metric} è di nuovo nel tuo intervallo abituale dopo {count} giorni — qualunque cosa fosse, è passata.",
|
||||
"daysInside": "{count} giorni dentro",
|
||||
"daysOutside": "{count} giorni fuori"
|
||||
}
|
||||
},
|
||||
"updatedTodayAt": "Aggiornato oggi, {time}",
|
||||
"updatedYesterday": "Aggiornato ieri",
|
||||
"updatedOn": "Aggiornato il {date}"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Predefinito",
|
||||
@@ -3677,8 +3711,8 @@
|
||||
"body": "Il diario delle malattie registra episodi acuti e diagnosi croniche con le loro riacutizzazioni. Nascosto: correlare un episodio alle tue misurazioni e la Modalità riposo che mette in pausa i promemoria. Così il Coach capisce perché i valori variano per un periodo."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prevenzione",
|
||||
"body": "Prevenzione ti ricorda controlli e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico."
|
||||
"title": "Controlli",
|
||||
"body": "I controlli ti ricordano screening e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
@@ -3702,7 +3736,7 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "Referto medico",
|
||||
"body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e prevenzione in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati."
|
||||
"body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e controlli in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Traguardi",
|
||||
@@ -4132,8 +4166,8 @@
|
||||
"subtitle": "Gestisci la vista e l’ordine degli episodi."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Prevenzione",
|
||||
"description": "Vista, ordine e singoli promemoria di prevenzione.",
|
||||
"title": "Controlli",
|
||||
"description": "Vista, ordine e singoli promemoria di controllo.",
|
||||
"subtitle": "Gestisci la vista, l’ordine e i singoli promemoria."
|
||||
},
|
||||
"modules": {
|
||||
@@ -4413,6 +4447,28 @@
|
||||
"clearedToast": "Dimenticate {count} informazioni memorizzate.",
|
||||
"clearError": "Impossibile cancellare le informazioni memorizzate. Riprova."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "I tuoi promemoria",
|
||||
"description": "Cose che hai chiesto al Coach di riprendere più tardi. Quelle in scadenza compaiono qui e nel Coach.",
|
||||
"empty": "Ancora nessun promemoria. Chiedi al Coach di ricordarti qualcosa e comparirà qui.",
|
||||
"loadError": "Impossibile caricare i tuoi promemoria.",
|
||||
"dueHeading": "In scadenza",
|
||||
"allHeading": "Salvati",
|
||||
"duePrefix": "in scadenza {when}",
|
||||
"confirm": "Mantieni",
|
||||
"markDone": "Fatto",
|
||||
"dismiss": "Ignora",
|
||||
"deleteAria": "Elimina questo promemoria",
|
||||
"note": "I promemoria sono memorizzati cifrati. Eliminane uno in qualsiasi momento — viene rimosso da ciò che il Coach può vedere.",
|
||||
"status": {
|
||||
"proposed": "Suggerito",
|
||||
"active": "Salvato",
|
||||
"due": "In scadenza",
|
||||
"surfaced": "Mostrato",
|
||||
"done": "Fatto",
|
||||
"dismissed": "Ignorato"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Su di me",
|
||||
"description": "Contesto personale che il Coach e il briefing giornaliero leggono insieme ai tuoi dati.",
|
||||
@@ -4436,7 +4492,24 @@
|
||||
"completeness": "{answered} su {total} compilati",
|
||||
"questionsTitle": "Il Coach ha alcune domande",
|
||||
"questionsOpenCoach": "Rispondi nel Coach"
|
||||
}
|
||||
},
|
||||
"responseTimeoutHeading": "Timeout della risposta",
|
||||
"responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.",
|
||||
"responseTimeoutLabel": "Timeout della risposta (secondi)",
|
||||
"responseTimeoutPlaceholder": "Predefinito (~180)",
|
||||
"responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.",
|
||||
"ocrHeading": "Scansione documenti (referti di laboratorio)",
|
||||
"ocrBody": "Usa un provider dedicato per leggere i referti di laboratorio caricati. Se disattivato, la scansione usa il tuo provider principale.",
|
||||
"ocrEnableLabel": "Attiva un provider dedicato per la scansione dei documenti",
|
||||
"ocrProviderLabel": "Provider di scansione",
|
||||
"ocrProviderOption": {
|
||||
"OPENAI": "OpenAI (la tua chiave API)",
|
||||
"ANTHROPIC": "Anthropic (Claude)",
|
||||
"LOCAL": "Modello locale (compatibile con OpenAI)"
|
||||
},
|
||||
"ocrModelPlaceholder": "es. gpt-4o",
|
||||
"ocrVisionHint": "Questo modello potrebbe non essere in grado di leggere le immagini. Scegli un modello con capacità di visione per la scansione dei documenti.",
|
||||
"ocrKeyLabel": "Chiave API"
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -4663,6 +4736,8 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentazione",
|
||||
"linksHeading": "Fonti e documentazione",
|
||||
"identityDescription": "Versione, licenza e dettagli di build di questa istanza.",
|
||||
"linksDescription": "Codice sorgente, registro delle modifiche e documentazione.",
|
||||
"newerAvailable": "Nuova versione: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -6483,13 +6558,13 @@
|
||||
"selectionBarLabel": "Azioni di selezione"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Prevenzione",
|
||||
"sectionTitle": "Controlli",
|
||||
"sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.",
|
||||
"addButton": "Aggiungi promemoria",
|
||||
"markDone": "Fatto",
|
||||
"delete": "Elimina",
|
||||
"deleteConfirmTitle": "Eliminare il promemoria?",
|
||||
"deleteConfirmDescription": "Questo promemoria di prevenzione verrà eliminato definitivamente.",
|
||||
"deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.",
|
||||
"disabledBadge": "Disattivato",
|
||||
"nextDue": {
|
||||
"none": "Non pianificato",
|
||||
@@ -6551,7 +6626,7 @@
|
||||
"rruleHint": "RFC-5545, es. FREQ=YEARLY per un controllo annuale.",
|
||||
"firstDue": "Prima scadenza (facoltativo)"
|
||||
},
|
||||
"pushTitle": "Promemoria di prevenzione",
|
||||
"pushTitle": "Promemoria di controllo",
|
||||
"pushBody": "È ora di: {label}",
|
||||
"pushLocation": "(presso {location})",
|
||||
"edit": "Modifica",
|
||||
@@ -6562,7 +6637,7 @@
|
||||
"measureNow": "Misura ora",
|
||||
"nextDueLabel": "Prossima scadenza:",
|
||||
"lastDoneLabel": "Ultima volta:",
|
||||
"customize": "Personalizza prevenzione",
|
||||
"customize": "Personalizza controlli",
|
||||
"adherence": {
|
||||
"label": "Nell'intervallo",
|
||||
"onTrack": "In linea",
|
||||
@@ -6578,8 +6653,8 @@
|
||||
"empty": "Nessun promemoria da personalizzare."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "Nessun promemoria di prevenzione in arrivo.",
|
||||
"openLink": "Apri prevenzione"
|
||||
"empty": "Nessun promemoria di controllo in arrivo.",
|
||||
"openLink": "Apri controlli"
|
||||
},
|
||||
"showMeasurements": "Mostra le misurazioni"
|
||||
},
|
||||
@@ -6720,7 +6795,14 @@
|
||||
"savedToast": "Biomarcatore salvato",
|
||||
"updatedToast": "Biomarcatore aggiornato",
|
||||
"saveError": "Impossibile salvare il biomarcatore"
|
||||
}
|
||||
},
|
||||
"hide": "Nascondi",
|
||||
"unhide": "Ripristina",
|
||||
"hiddenToast": "Biomarcatore nascosto",
|
||||
"unhiddenToast": "Biomarcatore ripristinato",
|
||||
"hideError": "Impossibile aggiornare il biomarcatore",
|
||||
"allHidden": "Tutti i biomarcatori sono nascosti.",
|
||||
"hiddenSection": "Nascosti"
|
||||
},
|
||||
"catalog": {
|
||||
"total-cholesterol": "Colesterolo totale",
|
||||
@@ -7041,6 +7123,25 @@
|
||||
"glucoseStructured": "Misura la glicemia con un programma giornaliero strutturato"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Aggiungi",
|
||||
"dismiss": "Non ora",
|
||||
"applied": "Aggiunto",
|
||||
"dismissed": "Ignorato",
|
||||
"failed": "Impossibile aggiungere — riprova",
|
||||
"checkup": {
|
||||
"title": "Aggiungere un promemoria di controllo?"
|
||||
},
|
||||
"note": {
|
||||
"title": "Salvare un promemoria?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Ogni anno",
|
||||
"halfYearly": "Ogni sei mesi",
|
||||
"quarterly": "Ogni tre mesi",
|
||||
"monthly": "Ogni mese"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Suggerimenti di cadenza",
|
||||
"reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente."
|
||||
|
||||
+120
-19
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
"vorsorge": "Profilaktyka",
|
||||
"vorsorge": "Badania",
|
||||
"illness": "Choroba"
|
||||
},
|
||||
"auth": {
|
||||
@@ -511,6 +511,7 @@
|
||||
"previousPage": "Poprzednia strona",
|
||||
"nextPage": "Następna strona",
|
||||
"dailyTotalCaption": "({count} pomiarów)",
|
||||
"openMetricInsights": "Otwórz analizę {type}",
|
||||
"sleepNapCaption": "+{count} drzemka/i",
|
||||
"sleepAwakeningsCaption": "{count} przebudzenia",
|
||||
"expandDay": "Pokaż pomiary z tego dnia",
|
||||
@@ -1945,7 +1946,6 @@
|
||||
"confidenceMittel": "Średnia",
|
||||
"confidenceGering": "Niska",
|
||||
"loadError": "Nie udało się załadować analiz",
|
||||
"heroGenerated": "Wygenerowano {time}",
|
||||
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
|
||||
"heroRegenerate": "Wygeneruj ponownie",
|
||||
"heroRegenerating": "Generowanie",
|
||||
@@ -1957,7 +1957,6 @@
|
||||
"narrativeMonth": "Ten miesiąc",
|
||||
"narrativePreparing": "Przygotowywanie podsumowania…",
|
||||
"narrativeUpdating": "Aktualizowanie…",
|
||||
"narrativeUpdated": "Zaktualizowano {time}",
|
||||
"narrativeProvenanceLabel": "Jak to powstało",
|
||||
"narrativeProvenanceMethod": "Podsumowanie okresu w prostym języku, oparte wyłącznie na zarejestrowanych wartościach i powiązaniach, które przeszły statystyczną korektę dla porównań wielokrotnych. Opisowe, nigdy nie przyczynowe.",
|
||||
"narrativeProvenanceMetrics": "Na podstawie: {metrics}",
|
||||
@@ -2098,6 +2097,9 @@
|
||||
"composerPlaceholder": "Zapytaj o swoje dane…",
|
||||
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
|
||||
"fabLabel": "Otwórz trenera",
|
||||
"readAloud": "Czytaj na głos",
|
||||
"readAloudStop": "Zatrzymaj czytanie",
|
||||
"messageTimeLabel": "Wiadomość wysłana {time}",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Dodać tę odpowiedź do Twoich informacji o sobie?",
|
||||
"confirm": "Dodaj",
|
||||
@@ -2211,6 +2213,8 @@
|
||||
"settingsSourcesPointerLink": "Otwórz coacha",
|
||||
"settingsExcludeLabel": "Wyklucz metryki",
|
||||
"settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.",
|
||||
"settingsContextLabel": "Wysyłaj kontekst",
|
||||
"settingsContextHint": "Opcjonalne bloki, na których coach może się opierać. Wyłączenie pominie blok w każdej odpowiedzi.",
|
||||
"settingsEvidenceLabel": "Pokazuj dowody domyślnie",
|
||||
"settingsEvidenceHint": "Automatycznie otwiera pole dowodów pod każdą odpowiedzią.",
|
||||
"settingsDefaultWindowLabel": "Domyślny okres analizy",
|
||||
@@ -2226,8 +2230,6 @@
|
||||
"dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).",
|
||||
"providerRateLimitTitle": "Dostawca z ograniczeniem",
|
||||
"providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.",
|
||||
"settingsContextLabel": "Wysyłaj kontekst",
|
||||
"settingsContextHint": "Opcjonalne bloki, na których coach może się opierać. Wyłączenie pominie blok w każdej odpowiedzi.",
|
||||
"cluster": {
|
||||
"cardio": {
|
||||
"label": "Sercowo-naczyniowe",
|
||||
@@ -2320,6 +2322,33 @@
|
||||
"groupYesterday": "Wczoraj",
|
||||
"groupThisWeek": "W tym tygodniu",
|
||||
"groupEarlier": "Wcześniej"
|
||||
},
|
||||
"seed": {
|
||||
"bloodPressure": "Przejdź ze mną przez trend mojego ciśnienia krwi z ostatnich 30 dni — czy jest coś, na co powinienem zwrócić uwagę?",
|
||||
"weight": "Jak zmieniała się ostatnio moja waga i czy ten kierunek wymaga reakcji?",
|
||||
"bmi": "Co mówi mi mój trend BMI i jak powinienem go odczytywać?",
|
||||
"pulse": "Przejdź ze mną przez moje odczyty tętna — czy coś odbiega od normy?",
|
||||
"restingHr": "Jak zmieniało się moje tętno spoczynkowe i co mówi o mojej kondycji?",
|
||||
"hrv": "Co moja zmienność rytmu serca mówi mi o regeneracji i stresie w ostatnim czasie?",
|
||||
"sleep": "Przejdź ze mną przez mój ostatni sen — długość, regularność i wszystko, co warto zmienić.",
|
||||
"mood": "Jakie wzorce widzisz w moim nastroju w ciągu ostatnich kilku tygodni?",
|
||||
"medications": "Jak wygląda moje przestrzeganie zaleceń lekowych i czy pokrywa się z tym, jak się czułem?",
|
||||
"steps": "Jak aktywny byłem ostatnio na podstawie moich kroków i jak wypada to na tle zdrowego punktu odniesienia?",
|
||||
"activeEnergy": "Co mój trend aktywnej energii mówi o moim poziomie aktywności w ostatnim czasie?",
|
||||
"cardioFitness": "Co oznacza trend mojej wydolności krążeniowo-oddechowej (VO₂ max) i jak skierować go we właściwą stronę?",
|
||||
"bloodGlucose": "Przejdź ze mną przez moje ostatnie odczyty glukozy — czy mieszczą się w zdrowym zakresie?",
|
||||
"oxygenSaturation": "Co mówi mi mój trend saturacji krwi tlenem (SpO₂)?",
|
||||
"respiratoryRate": "Co mój trend częstości oddechów mówi o moim zdrowiu?",
|
||||
"workouts": "Przejdź ze mną przez moje ostatnie treningi — obciążenie, częstotliwość i jak regeneruję się między nimi.",
|
||||
"recoveryPage": "Dlaczego moja regeneracja jest teraz na takim poziomie i co na nią wpływa?",
|
||||
"briefing": "Przejdź ze mną przez dzisiejszy briefing — na czym powinienem się skupić i czy jest coś, co warto podjąć?",
|
||||
"healthScore": "Dlaczego mój wynik zdrowia wynosi {score} na 100 i co poprawiłoby go najbardziej?",
|
||||
"periodWeek": "Przejdź ze mną przez to, jak minął mój ostatni tydzień w moich wskaźnikach zdrowia — co się wyróżnia?",
|
||||
"periodMonth": "Przejdź ze mną przez to, jak minął mój ostatni miesiąc w moich wskaźnikach zdrowia — co się wyróżnia?",
|
||||
"correlationBpCompliance": "Jak moje przestrzeganie zaleceń lekowych wiąże się z moim ciśnieniem krwi? Wyjaśnij mi, co ta korelacja oznacza dla mnie.",
|
||||
"correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.",
|
||||
"correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?",
|
||||
"recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
@@ -3240,6 +3269,7 @@
|
||||
},
|
||||
"vitals": {
|
||||
"sectionTitle": "Twoje parametry życiowe",
|
||||
"sectionSubtitle": "Spoczynkowe sygnały sercowo-naczyniowe i oddechowe na tle Twojej własnej linii bazowej",
|
||||
"loadingLabel": "Wczytywanie parametrów życiowych",
|
||||
"loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.",
|
||||
"building": "Budowanie Twojego typowego zakresu — {count} z {target} dni",
|
||||
@@ -3366,6 +3396,7 @@
|
||||
},
|
||||
"scores": {
|
||||
"sectionTitle": "Twoje wskaźniki zdrowia",
|
||||
"sectionSubtitle": "Złożone wartości regeneracji, stresu i obciążenia odczytane z Twoich połączonych urządzeń",
|
||||
"loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.",
|
||||
"recovery": "Regeneracja",
|
||||
"stress": "Stres",
|
||||
@@ -3611,7 +3642,10 @@
|
||||
"returnLabel": "Twój {metric} wrócił do twojego zwykłego zakresu po {count} dniach — cokolwiek to było, minęło.",
|
||||
"daysInside": "{count} dni w zakresie",
|
||||
"daysOutside": "{count} dni poza zakresem"
|
||||
}
|
||||
},
|
||||
"updatedTodayAt": "Zaktualizowano dzisiaj, {time}",
|
||||
"updatedYesterday": "Zaktualizowano wczoraj",
|
||||
"updatedOn": "Zaktualizowano {date}"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Domyślne",
|
||||
@@ -3677,8 +3711,8 @@
|
||||
"body": "Dziennik chorób zapisuje ostre epizody i przewlekłe diagnozy wraz z zaostrzeniami. Ukryte: korelacja epizodu z pomiarami oraz Tryb odpoczynku wstrzymujący przypomnienia. Dzięki temu Coach rozumie, czemu wartości przez chwilę odbiegają."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Profilaktyka",
|
||||
"body": "Profilaktyka przypomina o badaniach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza."
|
||||
"title": "Badania",
|
||||
"body": "Badania przypominają o przeglądach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
@@ -3702,7 +3736,7 @@
|
||||
},
|
||||
"export": {
|
||||
"title": "Raport dla lekarza",
|
||||
"body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i profilaktykę w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych."
|
||||
"body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i badania w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych."
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Osiągnięcia",
|
||||
@@ -4132,8 +4166,8 @@
|
||||
"subtitle": "Zarządzaj widokiem i kolejnością epizodów."
|
||||
},
|
||||
"vorsorge": {
|
||||
"title": "Profilaktyka",
|
||||
"description": "Widok, kolejność i poszczególne przypomnienia profilaktyczne.",
|
||||
"title": "Badania",
|
||||
"description": "Widok, kolejność i poszczególne przypomnienia o badaniach.",
|
||||
"subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami."
|
||||
},
|
||||
"modules": {
|
||||
@@ -4413,6 +4447,28 @@
|
||||
"clearedToast": "Zapomniano {count} zapisanych informacji.",
|
||||
"clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie."
|
||||
},
|
||||
"coachReminders": {
|
||||
"title": "Twoje przypomnienia",
|
||||
"description": "Rzeczy, o których przypomnienie poprosiłeś Coacha. Te, których termin nadszedł, pojawiają się tutaj i w Coachu.",
|
||||
"empty": "Brak przypomnień. Poproś Coacha, aby przypomniał Ci o czymś, a pojawi się tutaj.",
|
||||
"loadError": "Nie udało się wczytać Twoich przypomnień.",
|
||||
"dueHeading": "Termin teraz",
|
||||
"allHeading": "Zapisane",
|
||||
"duePrefix": "termin {when}",
|
||||
"confirm": "Zachowaj",
|
||||
"markDone": "Gotowe",
|
||||
"dismiss": "Odrzuć",
|
||||
"deleteAria": "Usuń to przypomnienie",
|
||||
"note": "Przypomnienia są przechowywane w postaci zaszyfrowanej. Usuń je w dowolnej chwili — znika z tego, co widzi Coach.",
|
||||
"status": {
|
||||
"proposed": "Sugerowane",
|
||||
"active": "Zapisane",
|
||||
"due": "Termin",
|
||||
"surfaced": "Wyświetlone",
|
||||
"done": "Gotowe",
|
||||
"dismissed": "Odrzucone"
|
||||
}
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "O mnie",
|
||||
"description": "Osobisty kontekst, który Coach i codzienny briefing czytają obok Twoich danych.",
|
||||
@@ -4436,7 +4492,24 @@
|
||||
"completeness": "Wypełnione: {answered} z {total}",
|
||||
"questionsTitle": "Coach ma pytania",
|
||||
"questionsOpenCoach": "Odpowiedz w Coachu"
|
||||
}
|
||||
},
|
||||
"responseTimeoutHeading": "Limit czasu odpowiedzi",
|
||||
"responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.",
|
||||
"responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)",
|
||||
"responseTimeoutPlaceholder": "Domyślnie (~180)",
|
||||
"responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.",
|
||||
"ocrHeading": "Skanowanie dokumentów (wyniki badań)",
|
||||
"ocrBody": "Użyj dedykowanego dostawcy do odczytu przesłanych wyników badań. Gdy wyłączone, skanowanie używa Twojego głównego dostawcy.",
|
||||
"ocrEnableLabel": "Włącz dedykowanego dostawcę do skanowania dokumentów",
|
||||
"ocrProviderLabel": "Dostawca skanowania",
|
||||
"ocrProviderOption": {
|
||||
"OPENAI": "OpenAI (Twój klucz API)",
|
||||
"ANTHROPIC": "Anthropic (Claude)",
|
||||
"LOCAL": "Model lokalny (zgodny z OpenAI)"
|
||||
},
|
||||
"ocrModelPlaceholder": "np. gpt-4o",
|
||||
"ocrVisionHint": "Ten model może nie odczytywać obrazów. Wybierz model obsługujący obraz do skanowania dokumentów.",
|
||||
"ocrKeyLabel": "Klucz API"
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -4663,6 +4736,8 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentacja",
|
||||
"linksHeading": "Źródła i dokumentacja",
|
||||
"identityDescription": "Wersja, licencja i szczegóły kompilacji tej instancji.",
|
||||
"linksDescription": "Kod źródłowy, dziennik zmian i dokumentacja.",
|
||||
"newerAvailable": "Nowa wersja: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -6483,13 +6558,13 @@
|
||||
"selectionBarLabel": "Akcje zaznaczenia"
|
||||
},
|
||||
"measurementReminders": {
|
||||
"sectionTitle": "Profilaktyka",
|
||||
"sectionTitle": "Badania",
|
||||
"sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.",
|
||||
"addButton": "Dodaj przypomnienie",
|
||||
"markDone": "Gotowe",
|
||||
"delete": "Usuń",
|
||||
"deleteConfirmTitle": "Usunąć przypomnienie?",
|
||||
"deleteConfirmDescription": "To przypomnienie profilaktyczne zostanie trwale usunięte.",
|
||||
"deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.",
|
||||
"disabledBadge": "Wył.",
|
||||
"nextDue": {
|
||||
"none": "Niezaplanowane",
|
||||
@@ -6551,7 +6626,7 @@
|
||||
"rruleHint": "RFC-5545, np. FREQ=YEARLY dla corocznego badania.",
|
||||
"firstDue": "Pierwszy termin (opcjonalnie)"
|
||||
},
|
||||
"pushTitle": "Przypomnienie profilaktyczne",
|
||||
"pushTitle": "Przypomnienie o badaniu",
|
||||
"pushBody": "Czas na: {label}",
|
||||
"pushLocation": "(u {location})",
|
||||
"edit": "Edytuj",
|
||||
@@ -6562,7 +6637,7 @@
|
||||
"measureNow": "Zmierz teraz",
|
||||
"nextDueLabel": "Następny termin:",
|
||||
"lastDoneLabel": "Ostatnio wykonano:",
|
||||
"customize": "Dostosuj profilaktykę",
|
||||
"customize": "Dostosuj badania",
|
||||
"adherence": {
|
||||
"label": "W interwale",
|
||||
"onTrack": "Zgodnie z planem",
|
||||
@@ -6578,8 +6653,8 @@
|
||||
"empty": "Brak przypomnień do dostosowania."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
|
||||
"openLink": "Otwórz profilaktykę"
|
||||
"empty": "Brak nadchodzących przypomnień o badaniach.",
|
||||
"openLink": "Otwórz badania"
|
||||
},
|
||||
"showMeasurements": "Pokaż pomiary"
|
||||
},
|
||||
@@ -6720,7 +6795,14 @@
|
||||
"savedToast": "Biomarker zapisany",
|
||||
"updatedToast": "Biomarker zaktualizowany",
|
||||
"saveError": "Nie udało się zapisać biomarkera"
|
||||
}
|
||||
},
|
||||
"hide": "Ukryj",
|
||||
"unhide": "Przywróć",
|
||||
"hiddenToast": "Biomarker ukryty",
|
||||
"unhiddenToast": "Biomarker przywrócony",
|
||||
"hideError": "Nie udało się zaktualizować biomarkera",
|
||||
"allHidden": "Wszystkie biomarkery są ukryte.",
|
||||
"hiddenSection": "Ukryte"
|
||||
},
|
||||
"catalog": {
|
||||
"total-cholesterol": "Cholesterol całkowity",
|
||||
@@ -7041,6 +7123,25 @@
|
||||
"glucoseStructured": "Mierz glukozę według ustrukturyzowanego planu dziennego"
|
||||
}
|
||||
},
|
||||
"suggestedAction": {
|
||||
"apply": "Dodaj",
|
||||
"dismiss": "Nie teraz",
|
||||
"applied": "Dodano",
|
||||
"dismissed": "Odrzucono",
|
||||
"failed": "Nie udało się dodać — spróbuj ponownie",
|
||||
"checkup": {
|
||||
"title": "Dodać przypomnienie o badaniu?"
|
||||
},
|
||||
"note": {
|
||||
"title": "Zapisać przypomnienie?"
|
||||
},
|
||||
"interval": {
|
||||
"yearly": "Co roku",
|
||||
"halfYearly": "Co sześć miesięcy",
|
||||
"quarterly": "Co trzy miesiące",
|
||||
"monthly": "Co miesiąc"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Sugestie częstotliwości",
|
||||
"reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie."
|
||||
|
||||
@@ -134,6 +134,21 @@ const nextConfig: NextConfig = {
|
||||
destination: "/settings/notifications",
|
||||
permanent: true,
|
||||
},
|
||||
// v1.22.0 — the preventive-care surface moved from the German slug
|
||||
// `/vorsorge` to the English `/checkups` (the feature reads "Checkups"
|
||||
// in every non-German locale now). 301-redirect so bookmarks, the PWA's
|
||||
// cached navigation, and any push deep-link keep resolving. The
|
||||
// persisted internal id stays `vorsorge`; only the URL changes.
|
||||
{
|
||||
source: "/vorsorge",
|
||||
destination: "/checkups",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/vorsorge/:path*",
|
||||
destination: "/checkups/:path*",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
async headers() {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.21.4",
|
||||
"version": "1.22.0",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- v1.22.0 — Biomarker catalog: per-marker "hidden" flag.
|
||||
--
|
||||
-- A marker the user no longer needs is hidden rather than deleted: it leaves
|
||||
-- the active catalog list and the lab-entry pickers, but its readings and its
|
||||
-- canonical unit/reference-range definition survive (unhide restores it). A
|
||||
-- plain additive boolean column with a server-side default; no existing row is
|
||||
-- touched (every back-filled row defaults to visible). `IF NOT EXISTS` makes
|
||||
-- the rerun safe.
|
||||
ALTER TABLE "biomarkers" ADD COLUMN IF NOT EXISTS "hidden" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- v1.22 (B2/B6) — Coach episodic reminder memory.
|
||||
--
|
||||
-- A time-anchored "bring this back later" intent the Coach captured from a
|
||||
-- conversation. The missing memory type between coach_facts (durable traits)
|
||||
-- and coach_plans (standing if-then habits): an episode with a due moment and
|
||||
-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea`
|
||||
-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted.
|
||||
-- metric / status / trigger fields / dates / source stay plain so the daily
|
||||
-- sweep and the injection picker can rank and filter without a per-row decrypt.
|
||||
-- status (proposed | active | due | surfaced | done | dismissed) and
|
||||
-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB
|
||||
-- enums so this migration stays purely additive. Soft-delete via deleted_at.
|
||||
CREATE TABLE "coach_reminders" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"note_encrypted" BYTEA NOT NULL,
|
||||
"metric" TEXT,
|
||||
"related_plan_id" TEXT,
|
||||
"trigger_kind" TEXT NOT NULL DEFAULT 'date',
|
||||
"due_at" TIMESTAMP(3),
|
||||
"context_cue" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"source" TEXT NOT NULL,
|
||||
"source_conversation_id" TEXT,
|
||||
"last_surfaced_at" TIMESTAMP(3),
|
||||
"surface_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- The daily sweep scans a user's active reminders whose due_at has passed; the
|
||||
-- status + deleted_at columns are part of that predicate, so the composite
|
||||
-- index lets the planner serve the sweep + the injection read index-backed.
|
||||
CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at");
|
||||
|
||||
-- The context-cue evaluation pass filters by trigger_kind + status per user.
|
||||
CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status");
|
||||
|
||||
ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- v1.22 (W9, C2) — n-of-1 experiment outcome read-back, encrypted at rest.
|
||||
-- An "experiment" is a CoachPlan with a reviewDate; when its review is
|
||||
-- processed, the grounded before/after result is written here. Additive,
|
||||
-- nullable Bytes column — no backfill, no enum change (the new statuses
|
||||
-- review_due / reviewed are app-side strings on the existing `status` column).
|
||||
ALTER TABLE "coach_plans" ADD COLUMN IF NOT EXISTS "outcome_encrypted" BYTEA;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- v1.22 (#89 + #90) — AI-settings wave.
|
||||
--
|
||||
-- Two additive, UI/DB-backed features on the per-user AI config (no new env
|
||||
-- vars — every value is configured in the app):
|
||||
--
|
||||
-- #89 ai_response_timeout_seconds — per-user response timeout for AI
|
||||
-- generation, in SECONDS. Threaded onto CompletionParams.timeoutMs
|
||||
-- (x1000). NULL = the generous built-in default (~180 s). Surfaced in
|
||||
-- Settings -> AI mainly for local/self-hosted servers where the first
|
||||
-- request can take >60 s while the model loads.
|
||||
--
|
||||
-- #90 dedicated document-scan (Lab-OCR) provider. OFF by default
|
||||
-- (ai_ocr_enabled = false) -> the OCR ingestion uses the main provider
|
||||
-- chain unchanged. When enabled, the OCR path resolves this dedicated
|
||||
-- provider/model/key instead. ai_ocr_key_encrypted is AES-256-GCM
|
||||
-- ciphertext like every other *_encrypted column (registered in the
|
||||
-- encrypted-columns registry + the rotation script).
|
||||
--
|
||||
-- All columns are nullable / defaulted, so existing rows need no backfill.
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "ai_response_timeout_seconds" INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS "ai_ocr_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "ai_ocr_provider" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "ai_ocr_model" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "ai_ocr_base_url" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "ai_ocr_key_encrypted" TEXT;
|
||||
+86
-2
@@ -254,6 +254,27 @@ model User {
|
||||
// behaviour (single-provider resolution via `aiProvider`).
|
||||
aiProviderChain Json? @map("ai_provider_chain")
|
||||
|
||||
// v1.22 (#89) — per-user response timeout for AI generation, in SECONDS.
|
||||
// Threaded onto `CompletionParams.timeoutMs` (×1000). Null = the generous
|
||||
// built-in default (~180 s). Surfaced in Settings → AI mainly for
|
||||
// local/self-hosted servers (an MLX/exo backend can take >60 s on the
|
||||
// first request while it loads the model), where the legacy 60 s client
|
||||
// default timed the Coach out before the first token landed.
|
||||
aiResponseTimeoutSeconds Int? @map("ai_response_timeout_seconds")
|
||||
|
||||
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. OFF by default:
|
||||
// the lab-report OCR path uses the main provider chain unchanged. When
|
||||
// `aiOcrEnabled` is true the OCR ingestion resolves THIS provider/model/key
|
||||
// instead, so a user can keep a cheap text Coach on one provider and route
|
||||
// expensive vision OCR to a vision-capable one. The key is AES-256-GCM at
|
||||
// rest like every other `*Encrypted` column (registered in
|
||||
// `src/lib/crypto/encrypted-columns.ts` + the rotation script).
|
||||
aiOcrEnabled Boolean @default(false) @map("ai_ocr_enabled")
|
||||
aiOcrProvider String? @map("ai_ocr_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL"
|
||||
aiOcrModel String? @map("ai_ocr_model")
|
||||
aiOcrBaseUrl String? @map("ai_ocr_base_url") // LOCAL OCR provider only
|
||||
aiOcrKeyEncrypted String? @map("ai_ocr_key_encrypted")
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
// v1.4.25 W14b — last persisted onboarding wizard step.
|
||||
@@ -571,6 +592,7 @@ model User {
|
||||
coachConversations CoachConversation[]
|
||||
coachFacts CoachFact[]
|
||||
coachPlans CoachPlan[]
|
||||
coachReminders CoachReminder[]
|
||||
coachUsage CoachUsage[]
|
||||
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
|
||||
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
|
||||
@@ -3657,6 +3679,10 @@ model Biomarker {
|
||||
/// Optional panel grouping moved off the per-entry row, e.g.
|
||||
/// "Großes Blutbild". NULL = standalone marker.
|
||||
panel String?
|
||||
/// A marker the user no longer needs: dropped from the active catalog list
|
||||
/// and from the lab-entry pickers, but never deleted — its readings and
|
||||
/// canonical unit/range definition survive.
|
||||
hidden Boolean @default(false) @map("hidden")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -3903,7 +3929,10 @@ model CoachFact {
|
||||
// so an abandoned plan is hidden from injection but kept for audit.
|
||||
//
|
||||
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
|
||||
// purely additive): proposed | active | met | abandoned.
|
||||
// purely additive): proposed | active | met | abandoned | review_due | reviewed.
|
||||
// The last two are the v1.22 (W9, C2) n-of-1 experiment lifecycle: an
|
||||
// "experiment" is just a CoachPlan with a reviewDate; when the review fires and
|
||||
// the read-back is generated, `outcomeEncrypted` carries the grounded result.
|
||||
model CoachPlan {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
@@ -3917,10 +3946,15 @@ model CoachPlan {
|
||||
// Optional free-text target ("70 kg by August", "lights out by 23:00"). Free
|
||||
// prose like the cue/action, so encrypted at rest as well.
|
||||
targetEncrypted Bytes? @map("target_encrypted")
|
||||
// App-side closed enum: proposed | active | met | abandoned.
|
||||
// App-side closed enum: proposed | active | met | abandoned | review_due |
|
||||
// reviewed (the last two are the W9/C2 experiment lifecycle).
|
||||
status String @default("proposed")
|
||||
// Optional review checkpoint the Coach can recall ("how did this go by …").
|
||||
reviewDate DateTime? @map("review_date")
|
||||
// v1.22 (W9, C2) — the n-of-1 read-back prose, grounded in the user's own
|
||||
// before/after numbers and encrypted at rest like the cue/action. Written
|
||||
// once when an experiment's review is processed; surfaced behind a flag.
|
||||
outcomeEncrypted Bytes? @map("outcome_encrypted")
|
||||
// Provenance — nullable so a future manual-entry surface can leave it null.
|
||||
sourceConversationId String? @map("source_conversation_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -3934,6 +3968,56 @@ model CoachPlan {
|
||||
@@map("coach_plans")
|
||||
}
|
||||
|
||||
// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a
|
||||
// time-anchored "bring this back later" intent the Coach captured from a
|
||||
// conversation ("remind me about my sleep next week"). This is the middle
|
||||
// memory type that neither CoachFact (durable traits) nor CoachPlan (standing
|
||||
// if-then habits) covers — an episode with a due moment and a done state.
|
||||
//
|
||||
// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM
|
||||
// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`,
|
||||
// `status`, the trigger fields and the dates stay plain so the daily sweep and
|
||||
// the injection picker can rank / filter without paying a per-row decrypt.
|
||||
//
|
||||
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
|
||||
// purely additive): proposed | active | due | surfaced | done | dismissed.
|
||||
// `triggerKind` is likewise app-side closed: date | context. Soft-delete via
|
||||
// `deletedAt` so a "forget this" action is reversible / auditable.
|
||||
model CoachReminder {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
// What to bring back — the user's own framing, encrypted at rest.
|
||||
noteEncrypted Bytes @map("note_encrypted")
|
||||
// Optional link to the metric / plan it concerns; plain for filtering.
|
||||
metric String?
|
||||
relatedPlanId String? @map("related_plan_id")
|
||||
// Trigger model — app-side closed enum (date | context). For `date`, `dueAt`
|
||||
// carries the fire moment; for `context`, `contextCue` names a closed cue.
|
||||
triggerKind String @default("date") @map("trigger_kind")
|
||||
dueAt DateTime? @map("due_at")
|
||||
contextCue String? @map("context_cue")
|
||||
// Lifecycle: proposed | active | due | surfaced | done | dismissed.
|
||||
status String @default("active")
|
||||
// Provenance — how it was captured (sentinel | extractor | manual | action).
|
||||
source String
|
||||
sourceConversationId String? @map("source_conversation_id")
|
||||
lastSurfacedAt DateTime? @map("last_surfaced_at")
|
||||
// How many times the sweep has surfaced this without the user acting; capped
|
||||
// before auto-dismiss so an ignored reminder cannot nag indefinitely.
|
||||
surfaceCount Int @default(0) @map("surface_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// The daily sweep scans active reminders whose dueAt has passed; the status +
|
||||
// deleted_at columns are part of the predicate so the read stays index-backed.
|
||||
@@index([userId, status, dueAt, deletedAt])
|
||||
@@index([userId, triggerKind, status])
|
||||
@@map("coach_reminders")
|
||||
}
|
||||
|
||||
// v1.15.20 — user-authored "about me" self-description the AI surfaces
|
||||
// (Coach system prompt + daily-briefing user prompt) inject as a clearly
|
||||
// delimited, user-provided context block. 1:1 with User and lazily
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.21.4";
|
||||
/* @sw-version-fallback */ "v1.22.0";
|
||||
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/*`
|
||||
|
||||
@@ -191,6 +191,7 @@ async function main() {
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"aiOpenaiKeyEncrypted",
|
||||
"aiOcrKeyEncrypted",
|
||||
"insuranceNumberEncrypted",
|
||||
];
|
||||
for (const field of userFields) {
|
||||
@@ -319,15 +320,26 @@ async function main() {
|
||||
);
|
||||
|
||||
// ───── CoachPlan (Bytes columns) ─────
|
||||
// "ifCueEncrypted" "thenActionEncrypted" "targetEncrypted"
|
||||
// "ifCueEncrypted" "thenActionEncrypted" "targetEncrypted" "outcomeEncrypted"
|
||||
for (const field of [
|
||||
"ifCueEncrypted",
|
||||
"thenActionEncrypted",
|
||||
"targetEncrypted",
|
||||
"outcomeEncrypted",
|
||||
]) {
|
||||
results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan));
|
||||
}
|
||||
|
||||
// ───── CoachReminder (Bytes column) ─────
|
||||
// "noteEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachReminder",
|
||||
"noteEncrypted",
|
||||
prisma.coachReminder,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── UserHealthProfile (Bytes columns) ─────
|
||||
// "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
|
||||
// "coachFocusEncrypted" "pendingQuestionsEncrypted"
|
||||
|
||||
@@ -39,6 +39,7 @@ function serialiseBiomarker(row: {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -54,6 +55,7 @@ function serialiseBiomarker(row: {
|
||||
context: row.contextEncrypted
|
||||
? decryptContextFromBytes(row.contextEncrypted)
|
||||
: null,
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -137,6 +139,7 @@ export const PUT = apiHandler(
|
||||
if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound;
|
||||
if (d.upperBound !== undefined) data.upperBound = d.upperBound;
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.hidden !== undefined) data.hidden = d.hidden;
|
||||
|
||||
// Inverted-range guard for a PARTIAL bound update. The schema refine only
|
||||
// fires when both bounds arrive together; moving a single bound past the
|
||||
|
||||
@@ -50,6 +50,7 @@ function serialiseBiomarker(row: {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -63,6 +64,7 @@ function serialiseBiomarker(row: {
|
||||
panel: row.panel,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: decryptContextSoft(row.contextEncrypted),
|
||||
hidden: row.hidden,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -71,9 +73,11 @@ function serialiseBiomarker(row: {
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Visible markers first, then hidden — the manager renders them in two
|
||||
// sections and the lab-entry pickers drop the hidden tail client-side.
|
||||
const rows = await prisma.biomarker.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: [{ name: "asc" }],
|
||||
orderBy: [{ hidden: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* PATCH /api/coach/reminders/[id] — confirm / update a reminder's lifecycle.
|
||||
* DELETE /api/coach/reminders/[id] — soft-delete one reminder.
|
||||
*
|
||||
* v1.22 (B2/B6) — the user-confirm + management surface for the Coach episodic
|
||||
* reminders. `status` confirms a `proposed` reminder (→ active), marks it
|
||||
* `done` / `dismissed`, or re-activates it; `when` re-schedules (snooze) via the
|
||||
* closed grammar. The body never carries the note text, so a client can never
|
||||
* inject or overwrite a reminder's prose — only its lifecycle / timing.
|
||||
*
|
||||
* Ownership + existence privacy: every mutation is scoped
|
||||
* `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user id,
|
||||
* an unknown id, or an already-deleted reminder all resolve to a `count: 0`
|
||||
* no-op rather than a P2025 throw — the existence channel never leaks across
|
||||
* accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent
|
||||
* `{ deleted: false }`.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { resolveWhenGrammar } from "@/lib/ai/coach/reminders";
|
||||
import { coachReminderPatchSchema } from "@/lib/validations/coach-reminder";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const MUTATE_RATE_LIMIT = 40;
|
||||
const MUTATE_WINDOW_MS = 60_000;
|
||||
|
||||
async function enforceMutateLimit(
|
||||
op: string,
|
||||
userId: string,
|
||||
): Promise<Response | null> {
|
||||
const rl = await checkRateLimit(
|
||||
`coach-reminders:${op}:${userId}`,
|
||||
MUTATE_RATE_LIMIT,
|
||||
MUTATE_WINDOW_MS,
|
||||
);
|
||||
if (rl.allowed) return null;
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceMutateLimit("patch", user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachReminderPatchSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// Field-by-field (no mass assignment): only the lifecycle / timing fields the
|
||||
// body carried are written; the note + encrypted text are never touched here.
|
||||
const data: {
|
||||
status?: string;
|
||||
dueAt?: Date | null;
|
||||
triggerKind?: string;
|
||||
contextCue?: string | null;
|
||||
} = {};
|
||||
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||
if (parsed.data.when !== undefined) {
|
||||
if (parsed.data.when === null) {
|
||||
data.dueAt = null;
|
||||
data.triggerKind = "date";
|
||||
data.contextCue = null;
|
||||
} else {
|
||||
const trigger = resolveWhenGrammar(parsed.data.when, new Date());
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
data.dueAt = trigger.dueAt;
|
||||
data.triggerKind = trigger.triggerKind;
|
||||
data.contextCue = trigger.contextCue;
|
||||
}
|
||||
}
|
||||
|
||||
const { count } = await prisma.coachReminder.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data,
|
||||
});
|
||||
|
||||
if (count === 0) return apiError("Reminder not found", 404);
|
||||
|
||||
const row = await prisma.coachReminder.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!row) return apiError("Reminder not found", 404);
|
||||
|
||||
let note: string | null = null;
|
||||
try {
|
||||
note = decryptFromBytes(row.noteEncrypted);
|
||||
} catch {
|
||||
note = null;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.updated" },
|
||||
meta: { status: row.status },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
reminder: {
|
||||
id: row.id,
|
||||
note,
|
||||
metric: row.metric,
|
||||
triggerKind: row.triggerKind,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
contextCue: row.contextCue,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceMutateLimit("delete", user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { count } = await prisma.coachReminder.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const deleted = count > 0;
|
||||
annotate({
|
||||
action: { name: "coach.reminders.deleted" },
|
||||
meta: { deleted },
|
||||
});
|
||||
return apiSuccess({ deleted });
|
||||
},
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* v1.22 (B2/B6) — `/api/coach/reminders` (+ `/[id]`) routes.
|
||||
*
|
||||
* Covers:
|
||||
* - GET lists the caller's reminders decrypted, scoped to the user; an
|
||||
* undecryptable row is skipped rather than 500ing the list.
|
||||
* - POST creates a reminder field-by-field (source manual, active), resolving
|
||||
* the closed `when` grammar server-side; an invalid `when` 422s.
|
||||
* - PATCH confirms proposed → active field-by-field; a 0-count match 404s
|
||||
* (no-IDOR: the cross-user / unknown id never leaks existence).
|
||||
* - DELETE soft-deletes the caller's reminder; a cross-user id is a 0-count
|
||||
* no-op returning `{ deleted: false }`.
|
||||
* - the coach module gate runs on every verb.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachReminder: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 39,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn((buf: Uint8Array) => {
|
||||
const tag = Buffer.from(buf).toString("utf8");
|
||||
if (tag === "__bad__") throw new Error("unknown key id");
|
||||
return `dec:${tag}`;
|
||||
}),
|
||||
encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(`enc:${s}`))),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH, DELETE as DELETE_ONE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "t",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
function bytes(tag: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(tag, "utf8"));
|
||||
}
|
||||
|
||||
const callGet = (url = "http://localhost/api/coach/reminders") =>
|
||||
(GET as unknown as (req: Request) => Promise<Response>)(new Request(url));
|
||||
|
||||
function jsonReq(body: unknown, method = "POST"): NextRequest {
|
||||
return new NextRequest("http://localhost/api/coach/reminders", {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
const callPost = (body: unknown) =>
|
||||
(POST as unknown as (req: Request) => Promise<Response>)(jsonReq(body));
|
||||
|
||||
const callPatch = (body: unknown, id = "r1") =>
|
||||
(
|
||||
PATCH as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(
|
||||
new NextRequest("http://localhost/api/coach/reminders/x", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
|
||||
const callDelete = (id = "r1") =>
|
||||
(
|
||||
DELETE_ONE as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(
|
||||
new NextRequest("http://localhost/api/coach/reminders/x", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("GET /api/coach/reminders", () => {
|
||||
it("lists the caller's reminders decrypted, scoped to the user", async () => {
|
||||
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "r1",
|
||||
metric: "SLEEP",
|
||||
noteEncrypted: bytes("revisit sleep"),
|
||||
triggerKind: "date",
|
||||
dueAt: new Date("2026-07-11T09:00:00Z"),
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "sentinel",
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { reminders: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(body.data.reminders[0]).toMatchObject({
|
||||
id: "r1",
|
||||
note: "dec:revisit sleep",
|
||||
status: "active",
|
||||
});
|
||||
const where = vi.mocked(prisma.coachReminder.findMany).mock.calls[0]?.[0]
|
||||
?.where as { userId: string; deletedAt: null };
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
|
||||
});
|
||||
|
||||
it("skips an undecryptable row rather than 500ing", async () => {
|
||||
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "r1",
|
||||
metric: null,
|
||||
noteEncrypted: bytes("__bad__"),
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
metric: null,
|
||||
noteEncrypted: bytes("ok"),
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet();
|
||||
const body = (await res.json()) as {
|
||||
data: { reminders: Array<{ id: string }> };
|
||||
};
|
||||
expect(body.data.reminders).toHaveLength(1);
|
||||
expect(body.data.reminders[0]?.id).toBe("r2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/coach/reminders", () => {
|
||||
it("creates a reminder field-by-field, resolving the when grammar", async () => {
|
||||
vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.coachReminder.create).mockResolvedValue({
|
||||
id: "r9",
|
||||
metric: "SLEEP",
|
||||
noteEncrypted: bytes("x"),
|
||||
triggerKind: "date",
|
||||
dueAt: new Date(),
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await callPost({
|
||||
note: "revisit sleep",
|
||||
when: "+7d",
|
||||
metric: "SLEEP",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0]
|
||||
?.data as {
|
||||
userId: string;
|
||||
status: string;
|
||||
source: string;
|
||||
};
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.status).toBe("active");
|
||||
expect(data.source).toBe("manual");
|
||||
});
|
||||
|
||||
it("422s on an invalid when token", async () => {
|
||||
vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never);
|
||||
const res = await callPost({ note: "x", when: "someday" });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on an unknown key (strict)", async () => {
|
||||
const res = await callPost({ note: "x", userId: "evil" });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/coach/reminders/[id]", () => {
|
||||
it("confirms proposed → active field-by-field, owner-scoped", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachReminder.findFirst).mockResolvedValue({
|
||||
id: "r1",
|
||||
metric: null,
|
||||
noteEncrypted: bytes("x"),
|
||||
triggerKind: "date",
|
||||
dueAt: null,
|
||||
contextCue: null,
|
||||
status: "active",
|
||||
source: "sentinel",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as never);
|
||||
const res = await callPatch({ status: "active" });
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.coachReminder.updateMany).mock
|
||||
.calls[0]?.[0] as {
|
||||
where: { id: string; userId: string; deletedAt: null };
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
expect(Object.keys(arg.data)).toEqual(["status"]);
|
||||
});
|
||||
|
||||
it("404s on a 0-count (cross-user / unknown) match — no IDOR leak", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callPatch({ status: "done" }, "someone-elses");
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.coachReminder.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on an empty body", async () => {
|
||||
const res = await callPatch({});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/coach/reminders/[id]", () => {
|
||||
it("soft-deletes the caller's reminder", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await callDelete();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: boolean } };
|
||||
expect(body.data.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it("returns deleted:false for a cross-user id (0-count no-op)", async () => {
|
||||
vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callDelete("someone-elses");
|
||||
const body = (await res.json()) as { data: { deleted: boolean } };
|
||||
expect(body.data.deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* GET /api/coach/reminders — list the caller's Coach episodic reminders.
|
||||
* POST /api/coach/reminders — create one manually (the ledger "+ add" path).
|
||||
*
|
||||
* v1.22 (B2/B6) — the management + in-app-surface read for the durable
|
||||
* "remind me about X" memory the Coach captures inline via the `---REMEMBER---`
|
||||
* sentinel. The note free-text is decrypted on the fly; an undecryptable row (a
|
||||
* key rotated out of the map) is skipped rather than 500ing the whole list.
|
||||
*
|
||||
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
|
||||
* only ever see / create their own reminders. The owner is always narrowed from
|
||||
* the session, never the body. Coach-gated by the same
|
||||
* `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach
|
||||
* management stack.
|
||||
*
|
||||
* `?status=` optionally filters (one status, or a comma set like `due,surfaced`
|
||||
* for the in-app tile). Omitted returns the non-terminal set (proposed / active
|
||||
* / due / surfaced), soonest-due first.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
resolveWhenGrammar,
|
||||
MAX_REMINDERS_PER_USER,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import {
|
||||
coachRemindersListQuerySchema,
|
||||
coachReminderCreateSchema,
|
||||
} from "@/lib/validations/coach-reminder";
|
||||
|
||||
const NON_TERMINAL = ["proposed", "active", "due", "surfaced"] as const;
|
||||
|
||||
interface ReminderRow {
|
||||
id: string;
|
||||
metric: string | null;
|
||||
noteEncrypted: Uint8Array;
|
||||
triggerKind: string;
|
||||
dueAt: Date | null;
|
||||
contextCue: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function toDto(row: ReminderRow, note: string) {
|
||||
return {
|
||||
id: row.id,
|
||||
note,
|
||||
metric: row.metric,
|
||||
triggerKind: row.triggerKind,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
contextCue: row.contextCue,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsed = coachRemindersListQuerySchema.safeParse({
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const statusWhere = parsed.data.status
|
||||
? { status: { in: parsed.data.status } }
|
||||
: { status: { in: [...NON_TERMINAL] } };
|
||||
|
||||
const rows = (await prisma.coachReminder.findMany({
|
||||
where: { userId: user.id, deletedAt: null, ...statusWhere },
|
||||
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})) as ReminderRow[];
|
||||
|
||||
const reminders: ReturnType<typeof toDto>[] = [];
|
||||
for (const row of rows) {
|
||||
let note: string;
|
||||
try {
|
||||
note = decryptFromBytes(row.noteEncrypted);
|
||||
} catch {
|
||||
// Fail closed per row — never surface ciphertext, never 500 the list.
|
||||
continue;
|
||||
}
|
||||
reminders.push(toDto(row, note));
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.listed" },
|
||||
meta: { count: reminders.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ reminders });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rate = await checkRateLimit(
|
||||
`coach-reminders:create:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rate.allowed) return apiError("Too many requests", 429);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachReminderCreateSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// Resolve the optional `when` against the closed grammar; an invalid token
|
||||
// 422s rather than silently dropping the timing the user asked for.
|
||||
const now = new Date();
|
||||
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
|
||||
if (parsed.data.when) {
|
||||
trigger = resolveWhenGrammar(parsed.data.when, now);
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
}
|
||||
|
||||
// Per-user cap on the non-terminal set.
|
||||
const nonTerminal = await prisma.coachReminder.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: { in: [...NON_TERMINAL] },
|
||||
},
|
||||
});
|
||||
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
|
||||
return apiError("coach.reminder.capReached", 409);
|
||||
}
|
||||
|
||||
// Field-by-field — no mass assignment. Manual reminders are active on create.
|
||||
const created = await prisma.coachReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
noteEncrypted: encryptToBytes(parsed.data.note),
|
||||
metric: parsed.data.metric ?? null,
|
||||
triggerKind: trigger?.triggerKind ?? "date",
|
||||
dueAt: trigger?.dueAt ?? null,
|
||||
contextCue: trigger?.contextCue ?? null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.created" },
|
||||
meta: { triggerKind: created.triggerKind, hasDue: created.dueAt != null },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{ reminder: toDto(created as ReminderRow, parsed.data.note) },
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* v1.22 (F6) — `POST /api/coach/suggested-actions`.
|
||||
*
|
||||
* Covers the closed allowlist confirm endpoint:
|
||||
* - checkup.create builds a Vorsorge MeasurementReminder field-by-field
|
||||
* (origin COACH, server-resolved RRULE from the closed interval id).
|
||||
* - reminder.note builds a CoachReminder field-by-field (source action).
|
||||
* - an off-allowlist actionType (e.g. medication.create) 422s — the moat
|
||||
* never auto-applies or mints an arbitrary entity.
|
||||
* - the owner is always the session user (no IDOR, no body userId).
|
||||
* - the coach module gate runs.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurementReminder: { create: vi.fn() },
|
||||
coachReminder: { count: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
resetAt: Date.now() + 1000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/scheduling", () => ({
|
||||
computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/dto", () => ({
|
||||
toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "t",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const call = (body: unknown) =>
|
||||
(POST as unknown as (req: Request) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/coach/suggested-actions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/coach/suggested-actions", () => {
|
||||
it("checkup.create builds a Vorsorge reminder field-by-field (origin COACH, server RRULE)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurementReminder.create).mockResolvedValue({
|
||||
id: "mr1",
|
||||
} as never);
|
||||
|
||||
const res = await call({
|
||||
actionType: "checkup.create",
|
||||
label: "Annual blood panel",
|
||||
interval: "yearly",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = vi.mocked(prisma.measurementReminder.create).mock.calls[0]?.[0]
|
||||
?.data as {
|
||||
userId: string;
|
||||
origin: string;
|
||||
rrule: string;
|
||||
measurementType: null;
|
||||
label: string;
|
||||
};
|
||||
expect(data.userId).toBe("user-1"); // owner from session, never the body
|
||||
expect(data.origin).toBe("COACH");
|
||||
expect(data.rrule).toBe("FREQ=YEARLY;INTERVAL=1");
|
||||
expect(data.measurementType).toBeNull();
|
||||
expect(data.label).toBe("Annual blood panel");
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
|
||||
});
|
||||
|
||||
it("reminder.note builds a CoachReminder field-by-field (source action)", async () => {
|
||||
vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.coachReminder.create).mockResolvedValue({
|
||||
id: "cr1",
|
||||
} as never);
|
||||
|
||||
const res = await call({
|
||||
actionType: "reminder.note",
|
||||
note: "revisit evening walk",
|
||||
when: "+14d",
|
||||
metric: "SLEEP",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0]
|
||||
?.data as {
|
||||
userId: string;
|
||||
source: string;
|
||||
status: string;
|
||||
};
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.source).toBe("action");
|
||||
expect(data.status).toBe("active");
|
||||
});
|
||||
|
||||
it("422s an off-allowlist actionType — never auto-applies a clinical/med change", async () => {
|
||||
const res = await call({
|
||||
actionType: "medication.create",
|
||||
label: "start a drug",
|
||||
interval: "monthly",
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurementReminder.create).not.toHaveBeenCalled();
|
||||
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("403s when the coach module is disabled", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
} as never);
|
||||
const res = await call({
|
||||
actionType: "reminder.note",
|
||||
note: "x",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* POST /api/coach/suggested-actions — confirm a generalised Coach action card.
|
||||
*
|
||||
* v1.22 (F6) — the confirm half of the generalised propose→confirm moat. The
|
||||
* Coach proposes an action via the closed `---SUGGEST-ACTION---` sentinel →
|
||||
* `suggestedAction` SSE frame → `<SuggestedActionCard>`. This endpoint is what
|
||||
* the card's confirm button calls. It switches on the CLOSED `actionType`
|
||||
* allowlist and, for each, builds the entity FIELD-BY-FIELD from a
|
||||
* server-resolved spec:
|
||||
*
|
||||
* - `checkup.create` → a preventive-care Vorsorge `MeasurementReminder`
|
||||
* (free-text label + a closed interval id the SERVER resolves to an RRULE),
|
||||
* minted through the SAME engine the Vorsorge surface uses.
|
||||
* - `reminder.note` → a `CoachReminder` (note + optional closed `when`
|
||||
* grammar + optional metric).
|
||||
*
|
||||
* HARD SAFETY: never auto-apply (this endpoint runs only on the explicit
|
||||
* confirm tap); closed allowlist only (the discriminated-union Zod gate rejects
|
||||
* any other `actionType`); NEVER a medication / dose / clinical-target change.
|
||||
* Cookie-auth, coach module-gated, per-user rate-limited, owner-scoped — no
|
||||
* IDOR, no mass assignment.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
resolveWhenGrammar,
|
||||
MAX_REMINDERS_PER_USER,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import { CHECKUP_INTERVAL_CATALOG } from "@/lib/ai/coach/suggest-action";
|
||||
import { coachSuggestedActionSchema } from "@/lib/validations/coach-reminder";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
const CHECKUP_NOTIFY_HOUR = 9;
|
||||
|
||||
async function postAction(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rate = await checkRateLimit(
|
||||
`coach:suggested-action:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rate.allowed) return apiError("coach.action.rateLimited", 429);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachSuggestedActionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const action = parsed.data;
|
||||
|
||||
// ── checkup.create → a preventive-care Vorsorge reminder ─────
|
||||
if (action.actionType === "checkup.create") {
|
||||
// The model named an interval id; the SERVER owns the RRULE so the model
|
||||
// can never mint an arbitrary schedule.
|
||||
const preset = CHECKUP_INTERVAL_CATALOG[action.interval];
|
||||
if (!preset) return apiError("coach.action.unknownInterval", 422);
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = row?.timezone || DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: null,
|
||||
rrule: preset.rrule,
|
||||
anchorDate: null,
|
||||
notifyHour: CHECKUP_NOTIFY_HOUR,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: now,
|
||||
endsOn: null,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
|
||||
// Field-by-field — no mass assignment. A free-text checklist Vorsorge
|
||||
// (measurementType null) so it resolves only on a manual "done". origin
|
||||
// COACH marks the provenance.
|
||||
const created = await prisma.measurementReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
label: action.label,
|
||||
measurementType: null,
|
||||
intervalDays: null,
|
||||
rrule: preset.rrule,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "COACH",
|
||||
notifyHour: CHECKUP_NOTIFY_HOUR,
|
||||
location: null,
|
||||
enabled: true,
|
||||
nextDueAt,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.create.coachAction", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: created.id, interval: action.interval },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.action.applied" },
|
||||
meta: { actionType: action.actionType, interval: action.interval },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
ok: true,
|
||||
actionType: action.actionType,
|
||||
reminder: toMeasurementReminderDto(created),
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
// ── reminder.note → a CoachReminder ──────────────────────────
|
||||
const now = new Date();
|
||||
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
|
||||
if (action.when) {
|
||||
trigger = resolveWhenGrammar(action.when, now);
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
}
|
||||
|
||||
const nonTerminal = await prisma.coachReminder.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: { in: ["proposed", "active", "due", "surfaced"] },
|
||||
},
|
||||
});
|
||||
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
|
||||
return apiError("coach.reminder.capReached", 409);
|
||||
}
|
||||
|
||||
const created = await prisma.coachReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
noteEncrypted: encryptToBytes(action.note),
|
||||
metric: action.metric ?? null,
|
||||
triggerKind: trigger?.triggerKind ?? "date",
|
||||
dueAt: trigger?.dueAt ?? null,
|
||||
contextCue: trigger?.contextCue ?? null,
|
||||
status: "active",
|
||||
source: "action",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.action.applied" },
|
||||
meta: {
|
||||
actionType: action.actionType,
|
||||
hasDue: trigger?.dueAt != null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{ ok: true, actionType: action.actionType, reminderId: created.id },
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(postAction);
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -50,12 +50,12 @@ vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback,
|
||||
runStreamingRawCompletionWithFallback,
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
// v1.20.0 (F1) — pin a no-tools provider so this suite exercises the legacy
|
||||
@@ -138,17 +138,18 @@ vi.mock("@/lib/validations/coach-prefs", () => ({
|
||||
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
|
||||
}));
|
||||
// v1.22 (#89) — the provider call now runs INSIDE the stream producer. Capture
|
||||
// the producer promise so the test can await it before asserting on call args.
|
||||
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
// Drain the producer synchronously so the handler completes; we only care
|
||||
// about the provider call args, not the wire frames.
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
signal: { aborted: boolean };
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
void Promise.resolve(
|
||||
producer({ signal: { aborted: true }, enqueue: () => {} }),
|
||||
sse.done = Promise.resolve(
|
||||
producer({ signal: { aborted: false }, enqueue: () => {} }),
|
||||
);
|
||||
return new ReadableStream();
|
||||
},
|
||||
@@ -158,6 +159,12 @@ import { POST } from "../route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
async function postAndDrain(body: Record<string, unknown>): Promise<Response> {
|
||||
const res = await post(chatReq(body));
|
||||
await sse.done;
|
||||
return res;
|
||||
}
|
||||
|
||||
function chatReq(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
@@ -167,7 +174,8 @@ function chatReq(body: Record<string, unknown>): Request {
|
||||
}
|
||||
|
||||
function lastUserPrompt(): string {
|
||||
const calls = runRawCompletionWithFallback.mock.calls as unknown as Array<
|
||||
const calls = runStreamingRawCompletionWithFallback.mock
|
||||
.calls as unknown as Array<
|
||||
[{ params: { messages: Array<{ role: string; content: string }> } }]
|
||||
>;
|
||||
const last = calls[calls.length - 1];
|
||||
@@ -178,14 +186,14 @@ function lastUserPrompt(): string {
|
||||
describe("coach chat — snapshot sent once per conversation (C4)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
runStreamingRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" },
|
||||
workingProvider: { providerType: "admin-openai" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ships the full SNAPSHOT + grounding on the first turn", async () => {
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
await postAndDrain({ message: "How is my BP?" });
|
||||
const prompt = lastUserPrompt();
|
||||
expect(prompt).toContain(SNAPSHOT_JSON);
|
||||
expect(prompt).toContain(GROUNDING);
|
||||
@@ -201,7 +209,7 @@ describe("coach chat — snapshot sent once per conversation (C4)", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await post(chatReq({ conversationId: "c1", message: "High?" }));
|
||||
await postAndDrain({ conversationId: "c1", message: "High?" });
|
||||
const prompt = lastUserPrompt();
|
||||
// The expensive figures + grounding must be gone …
|
||||
expect(prompt).not.toContain(SNAPSHOT_JSON);
|
||||
|
||||
@@ -52,12 +52,12 @@ vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runStreamingRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback,
|
||||
runStreamingRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
const { resolveProviderChain } = vi.hoisted(() => ({
|
||||
@@ -194,6 +194,10 @@ const { parseKeyValuesSentinel } = await import("@/lib/ai/coach/keyvalues");
|
||||
const { parseSuggestReminder } =
|
||||
await import("@/lib/ai/coach/suggest-reminder");
|
||||
|
||||
// v1.22 (#89) — the provider call + guards + persistence now run INSIDE the
|
||||
// stream producer (heartbeat-fronted). The mock captures the producer's promise
|
||||
// so tests can await it after `post()` before asserting on persisted effects.
|
||||
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
@@ -201,8 +205,9 @@ vi.mock("@/lib/sse/create-stream", () => ({
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
void Promise.resolve(
|
||||
producer({ signal: { aborted: true }, enqueue: () => {} }),
|
||||
// aborted:false so the producer streams to completion (persist + frames).
|
||||
sse.done = Promise.resolve(
|
||||
producer({ signal: { aborted: false }, enqueue: () => {} }),
|
||||
);
|
||||
return new ReadableStream();
|
||||
},
|
||||
@@ -212,6 +217,13 @@ import { POST } from "../route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
/** Run a POST and wait for the in-stream producer to finish. */
|
||||
async function postAndDrain(body: Record<string, unknown>): Promise<Response> {
|
||||
const res = await post(chatReq(body));
|
||||
await sse.done;
|
||||
return res;
|
||||
}
|
||||
|
||||
function chatReq(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
@@ -233,10 +245,10 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} }, // no supportsTools=false ⇒ tools on
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
await postAndDrain({ message: "How is my BP?" });
|
||||
|
||||
expect(runCoachToolLoop).toHaveBeenCalledTimes(1);
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(runStreamingRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
// The base context carries the inventory, NOT the snapshot figures.
|
||||
const loopArgs = (runCoachToolLoop.mock.calls[0] as unknown[])[0] as {
|
||||
messages: Array<{ content: string }>;
|
||||
@@ -277,6 +289,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
);
|
||||
|
||||
const res = await post(chatReq({ message: "How is my BP?" }));
|
||||
await sse.done;
|
||||
|
||||
// No throw → no 500. The provider-error frame streams over a 200.
|
||||
expect(res.status).toBe(200);
|
||||
@@ -293,7 +306,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
await postAndDrain({ message: "How is my BP?" });
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistantCall = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
@@ -338,7 +351,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
await postAndDrain({ message: "How is my BP?" });
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistantCall = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
@@ -353,14 +366,14 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
{ providerType: "local", instance: { supportsTools: false } },
|
||||
]);
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
runStreamingRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" },
|
||||
workingProvider: { providerType: "anthropic" },
|
||||
});
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
await postAndDrain({ message: "How is my BP?" });
|
||||
|
||||
expect(runCoachToolLoop).not.toHaveBeenCalled();
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
|
||||
expect(runStreamingRawCompletionWithFallback).toHaveBeenCalledTimes(1);
|
||||
// Single-round budget reservation in the fallback path; the 4th arg is the
|
||||
// provider-aware daily cap (F1).
|
||||
expect(reserveBudget).toHaveBeenCalledWith(
|
||||
@@ -371,7 +384,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
);
|
||||
// The legacy path ships the snapshot figures in the user turn.
|
||||
const params = (
|
||||
(runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as {
|
||||
(runStreamingRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as {
|
||||
params: { messages: Array<{ content: string }> };
|
||||
}
|
||||
).params;
|
||||
|
||||
+722
-466
File diff suppressed because it is too large.
Load diff
@@ -39,7 +39,11 @@ export const GET = apiHandler(async () => {
|
||||
prisma.coachMessage.findFirst({
|
||||
where: { role: "assistant", conversation: { userId: user.id } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
// v1.22 (W5) — also return the owning conversation id so the FAB can
|
||||
// deep-link straight into the proactive thread (`/coach?c=<id>`)
|
||||
// instead of opening a blank new chat and relying on a most-recent
|
||||
// heuristic to find it.
|
||||
select: { createdAt: true, conversationId: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
@@ -55,6 +59,10 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({
|
||||
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
|
||||
unread,
|
||||
// The conversation holding the newest assistant message. Present
|
||||
// whenever a Coach reply exists; the FAB only consumes it on the
|
||||
// unread path.
|
||||
conversationId: lastAssistant?.conversationId ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.22 — labs ↔ outcome pass fetches lab draws; default empty.
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -109,6 +111,8 @@ beforeEach(() => {
|
||||
(prisma.illnessDayLog.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
// v1.22 — labs ↔ outcome pass reads lab draws; default empty.
|
||||
(prisma.labResult.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -26,9 +26,12 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
discoverCorrelations,
|
||||
discoverEmergingCorrelations,
|
||||
discoverLabOutcomeCorrelations,
|
||||
discoveryMeasurementTypes,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
EARLY_WINDOW_DAYS,
|
||||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||||
type DailySeriesPoint,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import {
|
||||
fetchComplianceSeries,
|
||||
fetchLabDraws,
|
||||
fetchSymptomSeries,
|
||||
} from "@/lib/insights/correlation-channel-series";
|
||||
|
||||
@@ -147,8 +151,12 @@ export const GET = apiHandler(async () => {
|
||||
// v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from
|
||||
// their own sources. Each degrades to an empty series when the user has no
|
||||
// data, so the discovery loop drops the channel (it cannot clear n ≥ 20).
|
||||
const complianceSeries = await fetchComplianceSeries(user.id, tz, since);
|
||||
const symptomSeries = await fetchSymptomSeries(user.id, tz, since);
|
||||
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside.
|
||||
const [complianceSeries, symptomSeries, labDraws] = await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
]);
|
||||
|
||||
const points = (key: string): DailySeriesPoint[] =>
|
||||
key === "MOOD"
|
||||
@@ -175,6 +183,21 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const result = discoverCorrelations(series);
|
||||
|
||||
// v1.22 — rolling early-detection pass over the trailing window, re-using
|
||||
// the already-built series (no extra DB read). Emerging pairs exclude anything
|
||||
// the retrospective scan already established (no double-count).
|
||||
const recentFromDayKey = tzDayKey(
|
||||
new Date(now.getTime() - EARLY_WINDOW_DAYS * MS_PER_DAY),
|
||||
tz,
|
||||
);
|
||||
const emerging = discoverEmergingCorrelations(series, result, {
|
||||
recentFromDayKey,
|
||||
});
|
||||
|
||||
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). Degrades
|
||||
// to absent when the user has too few draws to clear the per-pair floor.
|
||||
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.correlations.discover" },
|
||||
meta: {
|
||||
@@ -185,8 +208,13 @@ export const GET = apiHandler(async () => {
|
||||
// two sparse new channels reached the n ≥ 20 floor or degraded to absent.
|
||||
compliance_days: complianceSeries.points.length,
|
||||
symptom_days: symptomSeries.points.length,
|
||||
// v1.22 — early-detection + labs reach.
|
||||
emerging: emerging.emerging.length,
|
||||
emerging_window_days: emerging.windowDays,
|
||||
lab_draws: labDraws.length,
|
||||
lab_correlations: labCorrelations.discovered.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
return apiSuccess({ ...result, emerging, labCorrelations });
|
||||
});
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
buildUserPrompt,
|
||||
type ComparisonSnapshot,
|
||||
} from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
buildSystemPromptWithReferences,
|
||||
buildBriefingPersonalisationBlock,
|
||||
} from "@/lib/ai/prompts/insight-generator";
|
||||
import {
|
||||
buildAboutMeInsightBlock,
|
||||
getSelfContextTextForUser,
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
singleUserTurn,
|
||||
type InsightResult,
|
||||
} from "@/lib/ai/types";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
@@ -397,6 +401,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
// v1.22 (W6) — first name for the sparse, hash-gated briefing opener.
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -665,6 +671,14 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale);
|
||||
}
|
||||
|
||||
// v1.22 (W6) — opener-archetype rotation + sparse first-name personalization
|
||||
// (deterministic per user+day; omitted for unnamed accounts).
|
||||
userPrompt += buildBriefingPersonalisationBlock(
|
||||
userId,
|
||||
dbUser?.displayName ?? null,
|
||||
locale,
|
||||
);
|
||||
|
||||
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
|
||||
// this briefing actually carries, so a normative claim can cite a real
|
||||
// `referenceId` the schema + UI footnote already support. Returns the plain
|
||||
@@ -700,6 +714,10 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
user: userPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
// v1.21.5 — wider upstream budget so the reasoning-heavy briefing
|
||||
// generation is not aborted at the client's 60 s default on large
|
||||
// accounts (which returned an empty briefing + trend narrative).
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// The reply is parsed with `JSON.parse` below, so opt the OpenAI /
|
||||
// Codex chains into their strict JSON mode (gated on this flag).
|
||||
responseFormat: "json",
|
||||
@@ -821,6 +839,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
let ungrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(insights),
|
||||
signals,
|
||||
features,
|
||||
);
|
||||
if (ungrounded.length > 0) {
|
||||
annotate({
|
||||
@@ -836,6 +855,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
// v1.21.5 — wider upstream budget; see the first generation call.
|
||||
timeoutMs: AI_BUDGETS.comprehensive.timeoutMs,
|
||||
// Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
@@ -848,6 +869,7 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
ungrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(retryInsights),
|
||||
signals,
|
||||
features,
|
||||
);
|
||||
if (ungrounded.length === 0) {
|
||||
insights = retryInsights;
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -35,14 +35,26 @@ vi.mock("@/lib/validations/notifications", () => ({
|
||||
isPublicUrl: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/local-host-allowlist", () => ({
|
||||
isLocalAiHostAllowed: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
|
||||
function patchRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
interface AiProviderResponse {
|
||||
data: {
|
||||
provider: string | null;
|
||||
@@ -126,4 +138,101 @@ describe("GET /api/user/ai-provider availability", () => {
|
||||
expect(body.data.aiAvailable).toBe(false);
|
||||
expect(body.data.managedBy).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the v1.22 timeout + OCR fields", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: "gpt-4o",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiResponseTimeoutSeconds: 240,
|
||||
aiOcrEnabled: true,
|
||||
aiOcrProvider: "ANTHROPIC",
|
||||
aiOcrModel: "claude-sonnet-4-6",
|
||||
aiOcrBaseUrl: null,
|
||||
aiOcrKeyEncrypted: "enc-ocr-1234",
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "user",
|
||||
});
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
responseTimeoutSeconds: number | null;
|
||||
ocrEnabled: boolean;
|
||||
ocrProvider: string | null;
|
||||
hasOcrKey: boolean;
|
||||
ocrKeyPreview: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.data.responseTimeoutSeconds).toBe(240);
|
||||
expect(body.data.ocrEnabled).toBe(true);
|
||||
expect(body.data.ocrProvider).toBe("ANTHROPIC");
|
||||
expect(body.data.hasOcrKey).toBe(true);
|
||||
// Preview is `...` + last 4 of the decrypted value (decrypt → `dec:...`).
|
||||
expect(body.data.ocrKeyPreview).toMatch(/^\.\.\./);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/ai-provider — v1.22 fields", () => {
|
||||
const patch = PATCH as (req: Request) => Promise<Response>;
|
||||
|
||||
it("persists a valid response timeout", async () => {
|
||||
const res = await patch(patchRequest({ responseTimeoutSeconds: 120 }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ aiResponseTimeoutSeconds: 120 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range response timeout", async () => {
|
||||
await expect(
|
||||
patch(patchRequest({ responseTimeoutSeconds: 5 })),
|
||||
).rejects.toThrow(/between 10 and 600/);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the timeout when null is sent", async () => {
|
||||
await patch(patchRequest({ responseTimeoutSeconds: null }));
|
||||
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ aiResponseTimeoutSeconds: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists OCR provider config and encrypts the key", async () => {
|
||||
const res = await patch(
|
||||
patchRequest({
|
||||
ocrEnabled: true,
|
||||
ocrProvider: "OPENAI",
|
||||
ocrModel: "gpt-4o",
|
||||
ocrKey: "sk-ocr-secret",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
aiOcrEnabled: true,
|
||||
aiOcrProvider: "OPENAI",
|
||||
aiOcrModel: "gpt-4o",
|
||||
aiOcrKeyEncrypted: "enc:sk-ocr-secret",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an invalid OCR provider", async () => {
|
||||
await expect(
|
||||
patch(patchRequest({ ocrProvider: "CHATGPT_OAUTH" })),
|
||||
).rejects.toThrow(/Invalid OCR provider/);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,9 @@ import { annotate } from "@/lib/logging/context";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]);
|
||||
// v1.22 (#90) — the dedicated document-scan provider has no OAuth path, so the
|
||||
// allowlist is the three keyed/self-hosted providers only (no CHATGPT_OAUTH).
|
||||
const OCR_PROVIDERS = new Set(["OPENAI", "ANTHROPIC", "LOCAL"]);
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -25,6 +28,13 @@ export const GET = apiHandler(async () => {
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
// v1.22 (#89 + #90)
|
||||
aiResponseTimeoutSeconds: true,
|
||||
aiOcrEnabled: true,
|
||||
aiOcrProvider: true,
|
||||
aiOcrModel: true,
|
||||
aiOcrBaseUrl: true,
|
||||
aiOcrKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -50,6 +60,18 @@ export const GET = apiHandler(async () => {
|
||||
openaiKeyPreview: u?.aiOpenaiKeyEncrypted
|
||||
? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
|
||||
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
|
||||
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider config. Key
|
||||
// material is presence + 4-char preview only, like every other provider.
|
||||
ocrEnabled: Boolean(u?.aiOcrEnabled),
|
||||
ocrProvider: u?.aiOcrProvider ?? null,
|
||||
ocrModel: u?.aiOcrModel ?? null,
|
||||
ocrBaseUrl: u?.aiOcrBaseUrl ?? null,
|
||||
hasOcrKey: Boolean(u?.aiOcrKeyEncrypted),
|
||||
ocrKeyPreview: u?.aiOcrKeyEncrypted
|
||||
? `...${decrypt(u.aiOcrKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +154,87 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ── v1.22 (#89) — response timeout (seconds) ──────────────────
|
||||
if (body.responseTimeoutSeconds !== undefined) {
|
||||
if (body.responseTimeoutSeconds === null) {
|
||||
updates.aiResponseTimeoutSeconds = null;
|
||||
} else if (
|
||||
typeof body.responseTimeoutSeconds === "number" &&
|
||||
Number.isInteger(body.responseTimeoutSeconds)
|
||||
) {
|
||||
// Sane bounds: 10 s floor (a stricter value just trips the timeout) and
|
||||
// 600 s ceiling (a generous cap for a slow self-hosted backend).
|
||||
if (
|
||||
body.responseTimeoutSeconds < 10 ||
|
||||
body.responseTimeoutSeconds > 600
|
||||
) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
"Response timeout must be between 10 and 600 seconds",
|
||||
);
|
||||
}
|
||||
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
|
||||
} else {
|
||||
throw new HttpError(422, "Invalid response timeout");
|
||||
}
|
||||
}
|
||||
|
||||
// ── v1.22 (#90) — dedicated document-scan (Lab-OCR) provider ──
|
||||
if (body.ocrEnabled !== undefined) {
|
||||
if (typeof body.ocrEnabled === "boolean") {
|
||||
updates.aiOcrEnabled = body.ocrEnabled;
|
||||
} else {
|
||||
throw new HttpError(422, "Invalid ocrEnabled");
|
||||
}
|
||||
}
|
||||
|
||||
if (body.ocrProvider !== undefined) {
|
||||
if (body.ocrProvider === null || body.ocrProvider === "") {
|
||||
updates.aiOcrProvider = null;
|
||||
} else if (
|
||||
typeof body.ocrProvider === "string" &&
|
||||
OCR_PROVIDERS.has(body.ocrProvider)
|
||||
) {
|
||||
updates.aiOcrProvider = body.ocrProvider;
|
||||
} else {
|
||||
throw new HttpError(422, "Invalid OCR provider");
|
||||
}
|
||||
}
|
||||
|
||||
if (body.ocrModel !== undefined) {
|
||||
if (body.ocrModel === null || body.ocrModel === "") {
|
||||
updates.aiOcrModel = null;
|
||||
} else if (typeof body.ocrModel === "string") {
|
||||
updates.aiOcrModel = body.ocrModel.trim() || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.ocrBaseUrl !== undefined) {
|
||||
if (body.ocrBaseUrl === null || body.ocrBaseUrl === "") {
|
||||
updates.aiOcrBaseUrl = null;
|
||||
} else if (typeof body.ocrBaseUrl === "string") {
|
||||
const trimmed = body.ocrBaseUrl.trim();
|
||||
// Same SSRF guard as the main LOCAL base URL: reject private/internal
|
||||
// hosts unless the operator opted in via ALLOW_LOCAL_AI_PRIVATE_HOSTS.
|
||||
const allowPrivate = isLocalAiHostAllowed(trimmed);
|
||||
if (!allowPrivate && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"OCR base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
updates.aiOcrBaseUrl = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.ocrKey !== undefined) {
|
||||
if (body.ocrKey === null || body.ocrKey === "") {
|
||||
updates.aiOcrKeyEncrypted = null;
|
||||
} else if (typeof body.ocrKey === "string") {
|
||||
updates.aiOcrKeyEncrypted = encrypt(body.ocrKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new HttpError(422, "No valid fields");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-sec
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated
|
||||
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
|
||||
* feature surface for "wann muss ich was wo machen". Auth-gated; the
|
||||
* section component owns the list + create flow.
|
||||
*
|
||||
@@ -20,7 +20,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* measurements): a pull invalidates the measurement-reminders read so the
|
||||
* list + server-computed next-due repaint.
|
||||
*/
|
||||
export default function VorsorgePage() {
|
||||
export default function CheckupsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -211,7 +211,7 @@ function CoachConversationsBody() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-lg"
|
||||
onClick={() => handleDeleteRequest(c.id)}
|
||||
aria-label={
|
||||
isConfirming
|
||||
@@ -221,7 +221,7 @@ function CoachConversationsBody() {
|
||||
data-slot="coach-conversations-delete"
|
||||
data-confirming={isConfirming ? "true" : undefined}
|
||||
className={cn(
|
||||
"size-11 shrink-0",
|
||||
"shrink-0",
|
||||
isConfirming &&
|
||||
"text-dracula-red hover:text-dracula-red",
|
||||
)}
|
||||
|
||||
@@ -316,7 +316,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
aria-label={t("admin.shell.sectionsNav")}
|
||||
className="hidden md:col-start-1 md:row-start-2 md:block"
|
||||
>
|
||||
<div className="sticky top-20">
|
||||
<div className="no-scrollbar sticky top-6 max-h-[calc(100dvh-5.5rem)] overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
<Link
|
||||
|
||||
@@ -32,7 +32,7 @@ export function DeleteButton({
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
className = "size-11",
|
||||
className = "",
|
||||
iconClassName = "h-3.5 w-3.5",
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
@@ -49,7 +49,7 @@ export function DeleteButton({
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-lg"
|
||||
className={`text-destructive ${className}`}
|
||||
aria-label={confirmLabel ?? t("common.delete")}
|
||||
>
|
||||
|
||||
@@ -198,8 +198,8 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
// At most amber — never destructive/red.
|
||||
expect(html).toContain("border-warning");
|
||||
expect(html).not.toContain("text-destructive");
|
||||
// The provenance affordance reaches the card.
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
// The provenance method reaches the card.
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
// The state change is announced politely to SR users (not assertively).
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DailyBriefing } from "../daily-briefing";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
|
||||
// The updated-at footer reads the profile timezone via `useAuth`; stub it so the
|
||||
// SSR render does not reach for a QueryClient the static-markup test omits.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { timezone: "Europe/Berlin" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B1 — full-width Daily Briefing card.
|
||||
*
|
||||
@@ -119,13 +129,15 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).toContain("bg-info"); // tone=info bar
|
||||
});
|
||||
|
||||
it("renders the 'Generated <time>' caption when updatedAt is supplied", () => {
|
||||
it("renders the 'Updated today, <time>' caption when updatedAt is supplied", () => {
|
||||
// v1.22 (W6) — the footer now uses `formatUpdatedLabel`: a same-day
|
||||
// timestamp reads "Updated today, HH:MM".
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<DailyBriefing briefing={baseBriefing} updatedAt={fiveMinutesAgo} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="daily-briefing-updated"/);
|
||||
expect(html).toContain("Generated");
|
||||
expect(html).toContain("Updated today");
|
||||
});
|
||||
|
||||
it("does NOT render the updated caption when updatedAt is missing", () => {
|
||||
|
||||
@@ -129,10 +129,11 @@ describe("<HealthScoreCard>", () => {
|
||||
});
|
||||
|
||||
it("threads aria-describedby from the delta text to the explainer body", () => {
|
||||
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the
|
||||
// explainer trigger's aria-controls reads the same id. Both
|
||||
// attributes share one `useId()` thread so SR can connect
|
||||
// "−3 vs last week" to the three-sentence read.
|
||||
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the inline
|
||||
// explainer caption carries the same id. v1.22 — the read no longer
|
||||
// hides behind a "?" trigger (no aria-controls); the body renders inline
|
||||
// and the shared `useId()` thread still connects "−3 vs last week" to the
|
||||
// three-sentence read.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
@@ -148,8 +149,12 @@ describe("<HealthScoreCard>", () => {
|
||||
expect(deltaSpan).not.toBeNull();
|
||||
const threadedId = deltaSpan?.[1] ?? "";
|
||||
expect(threadedId).not.toBe("");
|
||||
// The same id sits on the trigger button's aria-controls.
|
||||
expect(html).toContain(`aria-controls="${threadedId}"`);
|
||||
// The same id sits on the inline explainer body.
|
||||
expect(html).toMatch(
|
||||
new RegExp(
|
||||
`<span[^>]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders four component rows with their values", () => {
|
||||
|
||||
@@ -4,16 +4,15 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — FB-I1 — the "?" affordance next to the delta
|
||||
* line opens a 3-sentence read of which components contributed, what
|
||||
* the comparison window is, and one concrete next step.
|
||||
* v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a
|
||||
* three-sentence read of which components contributed, what the comparison
|
||||
* window is, and one concrete next step.
|
||||
*
|
||||
* Tests run through SSR so they don't depend on a browser surface.
|
||||
* The popover / sheet body mounts on click on the live page; the
|
||||
* SSR snapshot captures the trigger button (always present) plus a
|
||||
* locale-grounded check that the body copy actually exists in the
|
||||
* translation payload — the load-bearing constraint is "every
|
||||
* locale ships the 3-sentence read", not the popover open animation.
|
||||
* v1.22 — the read no longer hides behind a "?" glyph + popover; it renders
|
||||
* inline as a muted caption beside the delta line. These tests run through
|
||||
* SSR (no browser surface) and pin: the inline body paints by default, it
|
||||
* threads the parent-supplied id for aria-describedby, and every locale
|
||||
* ships the body copy.
|
||||
*/
|
||||
|
||||
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
@@ -23,114 +22,42 @@ function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that paints the explainer body via the same `t()` call the
|
||||
* popover uses. If a future refactor drops a locale key the test
|
||||
* fails at the `t()` call site rather than silently rendering the
|
||||
* fallback chevron.
|
||||
* Helper that paints the explainer body via the same `t()` call the caption
|
||||
* uses. If a future refactor drops a locale key the test fails at the `t()`
|
||||
* call site rather than silently rendering the literal key path.
|
||||
*/
|
||||
function BodyProbe() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div>
|
||||
<span data-slot="probe-trigger">
|
||||
{t("insights.healthScore.deltaExplainer.trigger")}
|
||||
</span>
|
||||
<span data-slot="probe-title">
|
||||
{t("insights.healthScore.deltaExplainer.title")}
|
||||
</span>
|
||||
<span data-slot="probe-body">
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
</div>
|
||||
<span data-slot="probe-body">
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HealthScoreDeltaExplainer>", () => {
|
||||
it("renders the icon-only trigger button with an accessible label (EN)", () => {
|
||||
it("renders the read inline as a muted caption (no trigger glyph)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
expect(html).toContain('data-slot="health-score-delta-explainer-body"');
|
||||
// The old icon-only disclosure trigger is gone.
|
||||
expect(html).not.toContain(
|
||||
'data-slot="health-score-delta-explainer-trigger"',
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"');
|
||||
});
|
||||
|
||||
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
|
||||
// The glyph stays 12 px but the click surface inflates to 44 px
|
||||
// via `min-h-11 min-w-11` on the trigger; the parent row keeps
|
||||
// its 16 px stride because `-my-3 -mx-2` swallows the extra
|
||||
// reach. Pinning so a future refactor can't drop back to the
|
||||
// visual-chip-as-hit-target shape that failed WCAG 2.5.5.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain("min-h-11");
|
||||
expect(trigger?.[0]).toContain("min-w-11");
|
||||
});
|
||||
|
||||
it("threads aria-expanded + aria-controls onto the trigger button", () => {
|
||||
// SR users get a clear "this opens a disclosure" cue via
|
||||
// aria-expanded; the aria-controls value is the same id the
|
||||
// body paints so the SR can jump to the explanation after
|
||||
// the user activates the trigger.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain("aria-expanded");
|
||||
expect(trigger?.[0]).toContain("aria-controls");
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
});
|
||||
|
||||
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
|
||||
// Parent owns the id and paints aria-describedby on the delta
|
||||
// span; the explainer re-uses the same id for its
|
||||
// aria-controls thread. The shared id is the seam SR uses to
|
||||
// jump from the digit to the explanation.
|
||||
const html = ssr(
|
||||
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
|
||||
);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
expect(html).toMatch(
|
||||
/<span[^>]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/,
|
||||
);
|
||||
expect(trigger?.[0]).toContain('aria-controls="parent-id-x"');
|
||||
});
|
||||
|
||||
it("does not wrap the trigger in a clickable <span> (single interactive element)", () => {
|
||||
// The earlier mobile path wrapped the button in a
|
||||
// `<span onClick onKeyDown>` which created two interactive
|
||||
// elements in the a11y tree and intercepted clicks on the 2 px
|
||||
// gap around the button. The button now owns the open toggle
|
||||
// directly. Pinning the negative so a future refactor can't
|
||||
// reintroduce the wrapper.
|
||||
it("paints the body copy on the initial SSR snapshot (always visible)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).not.toMatch(/<span[^>]*onclick/i);
|
||||
});
|
||||
|
||||
it("renders the trigger in German with the localised aria-label", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={2} />, "de");
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain(
|
||||
'aria-label="Was bedeutet diese Veränderung?"',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
|
||||
// Closed-by-default contract: the trigger button is the only
|
||||
// surface the SSR snapshot owns; the body lives in a portal that
|
||||
// mounts after the user taps. Pinning the negative side keeps a
|
||||
// future "open on render" refactor from leaking long-form copy
|
||||
// into the static markup.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).not.toContain('data-slot="health-score-delta-explainer-body"');
|
||||
expect(html).not.toContain(
|
||||
'data-slot="health-score-delta-explainer-title"',
|
||||
);
|
||||
expect(html).toContain("BP, weight, mood and medication adherence");
|
||||
});
|
||||
|
||||
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
|
||||
@@ -171,20 +98,18 @@ describe("<HealthScoreDeltaExplainer>", () => {
|
||||
expect(text).toContain("vor einer Woche");
|
||||
});
|
||||
|
||||
it("ships the trigger + title + body keys in every locale supported by R3c-Insights", () => {
|
||||
// Pinning every locale stops a future feature branch from
|
||||
// shipping an English fallback for the new explainer keys.
|
||||
it("ships the body key in every locale supported by R3c-Insights", () => {
|
||||
// Pinning every locale stops a future feature branch from shipping an
|
||||
// English fallback for the explainer body key.
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The probe re-uses the same `t()` payload the popover does,
|
||||
// so any missing key surfaces as the literal key path which
|
||||
// would never contain a space.
|
||||
expect(html).not.toContain("insights.healthScore.deltaExplainer.trigger");
|
||||
expect(html).not.toContain("insights.healthScore.deltaExplainer.title");
|
||||
// The probe re-uses the same `t()` payload the caption does, so any
|
||||
// missing key surfaces as the literal key path which never contains a
|
||||
// space.
|
||||
expect(html).not.toContain("insights.healthScore.deltaExplainer.body");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { HeroStrip } from "../hero-strip";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
|
||||
// The "generated" freshness line reads the profile timezone via `useAuth`; stub
|
||||
// it so the SSR render does not reach for a QueryClient the test omits.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { timezone: "Europe/Berlin" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Insights hero strip — pins the slots so future polish can't
|
||||
* silently drop the greeting / subtitle / baseline meta. v1.18.7
|
||||
@@ -94,7 +104,10 @@ describe("<HeroStrip>", () => {
|
||||
expect(html).toContain("Based on your last 90 days");
|
||||
});
|
||||
|
||||
it("renders the generated-time caption when updatedAt is supplied", () => {
|
||||
it("renders the freshness caption when updatedAt is supplied", () => {
|
||||
// v1.22 — the hero freshness line now uses `formatUpdatedLabel` for parity
|
||||
// with the briefing + per-metric cards: a same-day timestamp reads
|
||||
// "Updated today, HH:MM" rather than the old relative "Generated … ago".
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
@@ -104,7 +117,7 @@ describe("<HeroStrip>", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insights-hero-strip-generated"/);
|
||||
expect(html).toContain("Generated");
|
||||
expect(html).toContain("Updated today");
|
||||
});
|
||||
|
||||
it("does NOT render the generated caption when updatedAt is missing", () => {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightStatusCard } from "../insight-status-card";
|
||||
|
||||
// The last-updated footer reads the profile timezone via `useAuth`; stub it so
|
||||
// the SSR render does not reach for a QueryClient the static-markup test omits.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { timezone: "Europe/Berlin" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.27 — F16 regression cover. The per-metric status text comes
|
||||
* out of the AI provider with a literal `metric:<TYPE>` token (the
|
||||
|
||||
@@ -171,20 +171,42 @@ describe("isNudgeUnread", () => {
|
||||
|
||||
it("is false without status or without a nudge", () => {
|
||||
expect(isNudgeUnread(undefined, null)).toBe(false);
|
||||
expect(isNudgeUnread({ nudgedAt: null, unread: false }, null)).toBe(false);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt: null, unread: false, conversationId: null },
|
||||
null,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a server-unread nudge the device has not seen", () => {
|
||||
expect(isNudgeUnread({ nudgedAt, unread: true }, null)).toBe(true);
|
||||
expect(isNudgeUnread({ nudgedAt, unread: true }, "2026-01-01")).toBe(true);
|
||||
expect(
|
||||
isNudgeUnread({ nudgedAt, unread: true, conversationId: "conv-1" }, null),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt, unread: true, conversationId: "conv-1" },
|
||||
"2026-01-01",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false once the server counts the nudge as read", () => {
|
||||
// The user sent a Coach message after the nudge.
|
||||
expect(isNudgeUnread({ nudgedAt, unread: false }, null)).toBe(false);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt, unread: false, conversationId: "conv-1" },
|
||||
null,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false once this device stored the matching seen stamp", () => {
|
||||
expect(isNudgeUnread({ nudgedAt, unread: true }, nudgedAt)).toBe(false);
|
||||
expect(
|
||||
isNudgeUnread(
|
||||
{ nudgedAt, unread: true, conversationId: "conv-1" },
|
||||
nudgedAt,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
ProseBlocks,
|
||||
splitParagraphs,
|
||||
} from "@/components/insights/prose-blocks";
|
||||
import {
|
||||
StreamedProse,
|
||||
splitProseSegments,
|
||||
} from "@/components/insights/coach-panel/streamed-prose";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("splitParagraphs", () => {
|
||||
it("splits on blank lines and trims, dropping empties", () => {
|
||||
expect(splitParagraphs("one\n\ntwo")).toEqual(["one", "two"]);
|
||||
expect(splitParagraphs(" a \n\n\n b ")).toEqual(["a", "b"]);
|
||||
expect(splitParagraphs("solo")).toEqual(["solo"]);
|
||||
expect(splitParagraphs("\n\n \n\n")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a single newline inside one paragraph", () => {
|
||||
expect(splitParagraphs("line1\nline2")).toEqual(["line1\nline2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProseBlocks", () => {
|
||||
it("renders blank-line-separated text as real <p> blocks", () => {
|
||||
const html = render(<ProseBlocks text={"First idea.\n\nSecond idea."} />);
|
||||
const paras = (html.match(/<p\b/g) ?? []).length;
|
||||
expect(paras).toBe(2);
|
||||
expect(html).toContain("First idea.");
|
||||
expect(html).toContain("Second idea.");
|
||||
});
|
||||
|
||||
it("turns a single newline into a <br/>", () => {
|
||||
const html = render(<ProseBlocks text={"line a\nline b"} />);
|
||||
expect(html).toContain("<br/>");
|
||||
expect((html.match(/<p\b/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("strips stray chart tokens from the prose by default", () => {
|
||||
const html = render(
|
||||
<ProseBlocks text={"Your weight metric:WEIGHT held."} />,
|
||||
);
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Your weight");
|
||||
});
|
||||
|
||||
it("linkifies a catalog-known /learn/<slug> reference as a safe anchor", () => {
|
||||
const html = render(
|
||||
<ProseBlocks
|
||||
text={"More on this: https://healthlog.dev/learn/resting-heart-rate"}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="inline-learn-link"');
|
||||
expect(html).toContain("/learn/resting-heart-rate");
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it("leaves an unknown /learn slug as plain text (fail-closed)", () => {
|
||||
const html = render(
|
||||
<ProseBlocks text={"see https://healthlog.dev/learn/totally-made-up"} />,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="inline-learn-link"');
|
||||
expect(html).toContain("/learn/totally-made-up");
|
||||
});
|
||||
|
||||
it("does not strip or linkify when both are disabled (user text)", () => {
|
||||
const html = render(
|
||||
<ProseBlocks
|
||||
text={"keep metric:WEIGHT and /learn/resting-heart-rate verbatim"}
|
||||
strip={false}
|
||||
linkify={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("metric:WEIGHT");
|
||||
expect(html).not.toContain('data-slot="inline-learn-link"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamedProse", () => {
|
||||
it("settles to real paragraph blocks when not streaming", () => {
|
||||
const html = render(
|
||||
<StreamedProse
|
||||
content={"Where things stand.\n\nOne thing to try."}
|
||||
streaming={false}
|
||||
/>,
|
||||
);
|
||||
expect((html.match(/<p\b/g) ?? []).length).toBe(2);
|
||||
expect(html).toContain("Where things stand.");
|
||||
expect(html).toContain("One thing to try.");
|
||||
});
|
||||
|
||||
it("animates only the growing tail paragraph while streaming", () => {
|
||||
const html = render(
|
||||
<StreamedProse
|
||||
content={"Settled paragraph.\n\nGrowing tail words"}
|
||||
streaming
|
||||
/>,
|
||||
);
|
||||
// The completed first paragraph settles as plain text (no spans).
|
||||
expect(html).toContain("Settled paragraph.");
|
||||
// The tail renders each word in its own fade-in span.
|
||||
for (const word of ["Growing ", "tail ", "words"]) {
|
||||
expect(html).toContain(`>${word}</span>`);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not leak a chart token into streamed prose", () => {
|
||||
const html = render(
|
||||
<StreamedProse content={"Trending up metric:WEIGHT"} streaming />,
|
||||
);
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitProseSegments", () => {
|
||||
it("keeps each word with its trailing whitespace", () => {
|
||||
expect(splitProseSegments("a b c")).toEqual(["a ", "b ", "c"]);
|
||||
expect(splitProseSegments("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,10 @@ import { COACH_SOURCE_DOMAIN_LABEL } from "@/lib/ai/coach/tools/source-keys";
|
||||
* - the "Ask the Coach" affordance on insight/assessment cards passes
|
||||
* an explicit scope so a card tap lands a pre-scoped conversation.
|
||||
*
|
||||
* Seed questions are plain English strings, mirroring the empty-state
|
||||
* `coachPrefill` convention already in the tree — the Coach chat route
|
||||
* is English/German-gated and treats the prefill as composer seed text,
|
||||
* not an i18n key.
|
||||
* v1.22 (W6) — seed questions are i18n KEYS (`insights.coach.seed.*`), not
|
||||
* hardcoded English: a German user was getting an English composer seed. The
|
||||
* consumer (`SubPageShell`) resolves the key through `t()` before handing it to
|
||||
* the composer, so the seed reads in the user's language.
|
||||
*/
|
||||
|
||||
export interface CoachMetricScope {
|
||||
@@ -30,7 +30,7 @@ export interface CoachMetricScope {
|
||||
also?: CoachScopeSource[];
|
||||
/** Optional day-window override; defaults to the route's `last30days`. */
|
||||
window?: CoachScopeWindow;
|
||||
/** Composer seed question — a data-aware opener for the metric. */
|
||||
/** i18n key for the composer seed question — resolved by the consumer. */
|
||||
question: string;
|
||||
}
|
||||
|
||||
@@ -45,79 +45,67 @@ export interface CoachMetricScope {
|
||||
const EXPLAINER_METRIC_SCOPE: Record<string, CoachMetricScope> = {
|
||||
bloodPressure: {
|
||||
metric: "bp",
|
||||
question:
|
||||
"Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?",
|
||||
question: "insights.coach.seed.bloodPressure",
|
||||
},
|
||||
weight: {
|
||||
metric: "weight",
|
||||
question:
|
||||
"How has my weight been trending lately, and is the direction something to act on?",
|
||||
question: "insights.coach.seed.weight",
|
||||
},
|
||||
bmi: {
|
||||
metric: "bmi",
|
||||
question: "What does my BMI trend tell me, and how should I read it?",
|
||||
question: "insights.coach.seed.bmi",
|
||||
},
|
||||
pulse: {
|
||||
metric: "pulse",
|
||||
question:
|
||||
"Walk me through my pulse readings — is anything out of the ordinary?",
|
||||
question: "insights.coach.seed.pulse",
|
||||
},
|
||||
restingHr: {
|
||||
metric: "resting_hr",
|
||||
question:
|
||||
"How has my resting heart rate been trending, and what does it say about my fitness?",
|
||||
question: "insights.coach.seed.restingHr",
|
||||
},
|
||||
hrv: {
|
||||
metric: "hrv",
|
||||
question:
|
||||
"What is my heart-rate variability telling me about recovery and stress lately?",
|
||||
question: "insights.coach.seed.hrv",
|
||||
},
|
||||
sleep: {
|
||||
metric: "sleep",
|
||||
question:
|
||||
"Walk me through my recent sleep — duration, consistency, and anything worth changing.",
|
||||
question: "insights.coach.seed.sleep",
|
||||
},
|
||||
mood: {
|
||||
metric: "mood",
|
||||
question: "What patterns do you see in my mood over the last few weeks?",
|
||||
question: "insights.coach.seed.mood",
|
||||
},
|
||||
medications: {
|
||||
metric: "compliance",
|
||||
question:
|
||||
"How is my medication adherence looking, and is it lined up with how I've been feeling?",
|
||||
question: "insights.coach.seed.medications",
|
||||
},
|
||||
steps: {
|
||||
metric: "steps",
|
||||
question:
|
||||
"How active have I been lately based on my steps, and how does it compare to a healthy baseline?",
|
||||
question: "insights.coach.seed.steps",
|
||||
},
|
||||
activeEnergy: {
|
||||
metric: "active_energy",
|
||||
question:
|
||||
"What does my active-energy trend say about my activity level lately?",
|
||||
question: "insights.coach.seed.activeEnergy",
|
||||
},
|
||||
cardioFitness: {
|
||||
metric: "vo2_max",
|
||||
question:
|
||||
"What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?",
|
||||
question: "insights.coach.seed.cardioFitness",
|
||||
},
|
||||
bloodGlucose: {
|
||||
metric: "glucose",
|
||||
question:
|
||||
"Walk me through my recent glucose readings — are they in a healthy range?",
|
||||
question: "insights.coach.seed.bloodGlucose",
|
||||
},
|
||||
oxygenSaturation: {
|
||||
metric: "spo2",
|
||||
question: "What does my blood-oxygen (SpO₂) trend tell me?",
|
||||
question: "insights.coach.seed.oxygenSaturation",
|
||||
},
|
||||
respiratoryRate: {
|
||||
metric: "respiratory_rate",
|
||||
question: "What does my respiratory-rate trend say about my health?",
|
||||
question: "insights.coach.seed.respiratoryRate",
|
||||
},
|
||||
workouts: {
|
||||
metric: "workouts",
|
||||
question:
|
||||
"Walk me through my recent workouts — load, frequency, and how I'm recovering between them.",
|
||||
question: "insights.coach.seed.workouts",
|
||||
},
|
||||
// Recovery is a synthesis page; anchor on HRV + resting HR + sleep so the
|
||||
// Coach reads the inputs that drive the recovery read.
|
||||
@@ -125,8 +113,7 @@ const EXPLAINER_METRIC_SCOPE: Record<string, CoachMetricScope> = {
|
||||
metric: "hrv",
|
||||
also: ["resting_hr", "sleep"],
|
||||
window: "last7days",
|
||||
question:
|
||||
"Why is my recovery where it is right now, and what's driving it?",
|
||||
question: "insights.coach.seed.recoveryPage",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// Mirror the message-thread test harness: SSR render with no providers, so
|
||||
// stub the hooks the bubble reaches for.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "tester", role: "USER", avatarUrl: null },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
|
||||
}));
|
||||
|
||||
import { MessageThread, selectCoachChartTokens } from "../message-thread";
|
||||
import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("selectCoachChartTokens", () => {
|
||||
it("returns an allowlisted token grounded in the turn's provenance", () => {
|
||||
expect(
|
||||
selectCoachChartTokens("The trend metric:WEIGHT is up", ["weight"]),
|
||||
).toEqual(["metric:WEIGHT"]);
|
||||
});
|
||||
|
||||
it("drops a token whose metric is not in the grounded provenance", () => {
|
||||
expect(selectCoachChartTokens("metric:WEIGHT", [])).toEqual([]);
|
||||
expect(selectCoachChartTokens("metric:WEIGHT", ["pulse"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a hallucinated / non-allowlisted token", () => {
|
||||
expect(selectCoachChartTokens("metric:NUKE", ["weight"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits synthetic tokens without a self-fetching chart (MOOD)", () => {
|
||||
expect(selectCoachChartTokens("metric:MOOD", ["mood"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("de-duplicates by metric and caps the number rendered", () => {
|
||||
const tokens = selectCoachChartTokens(
|
||||
"metric:WEIGHT metric:WEIGHT metric:PULSE metric:BLOOD_GLUCOSE",
|
||||
["weight", "pulse", "glucose"],
|
||||
);
|
||||
expect(tokens).toEqual(["metric:WEIGHT", "metric:PULSE"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coach chart rendering", () => {
|
||||
const conversation: CoachConversationDetailDTO = {
|
||||
id: "c1",
|
||||
title: "Test",
|
||||
createdAt: "2026-06-01T10:00:00.000Z",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
messageCount: 1,
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: "Your weight is trending down. metric:WEIGHT",
|
||||
createdAt: "2026-06-01T10:00:00.000Z",
|
||||
metricSource: { windows: ["last30days"], metrics: ["weight"] },
|
||||
providerType: "openai",
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("mounts a chart for a grounded token and strips it from the prose", () => {
|
||||
const html = render(<MessageThread conversation={conversation} />);
|
||||
expect(html).toContain('data-slot="coach-charts"');
|
||||
expect(html).toContain('data-slot="coach-chart"');
|
||||
// The literal token must never surface in the rendered prose.
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Your weight is trending down");
|
||||
});
|
||||
|
||||
it("renders no chart when the token is not grounded in provenance", () => {
|
||||
const ungrounded: CoachConversationDetailDTO = {
|
||||
...conversation,
|
||||
messages: [
|
||||
{
|
||||
...conversation.messages[0],
|
||||
metricSource: { windows: [], metrics: ["pulse"] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={ungrounded} />);
|
||||
expect(html).not.toContain('data-slot="coach-chart"');
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,8 @@ describe("metricScopeFromExplainer", () => {
|
||||
it("resolves a mapped explainer token to a scope + opener", () => {
|
||||
const resolved = metricScopeFromExplainer("bloodPressure");
|
||||
expect(resolved?.metric).toBe("bp");
|
||||
expect(resolved?.question).toMatch(/blood pressure/i);
|
||||
// v1.22 (W6) — `question` is now an i18n key (resolved by the consumer).
|
||||
expect(resolved?.question).toBe("insights.coach.seed.bloodPressure");
|
||||
});
|
||||
|
||||
it("anchors the recovery page on its driver sources + short window", () => {
|
||||
|
||||
@@ -73,6 +73,7 @@ function streaming(
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
suggestedAction: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
|
||||
@@ -165,12 +165,12 @@ export function CoachDrawer({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-lg"
|
||||
onClick={handleMaximize}
|
||||
data-slot="coach-drawer-maximize"
|
||||
aria-label={t("insights.coach.maximizeAriaLabel")}
|
||||
title={t("insights.coach.maximizeAriaLabel")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Maximize2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
@@ -183,11 +183,11 @@ export function CoachDrawer({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="icon-lg"
|
||||
data-slot="coach-drawer-close"
|
||||
aria-label={t("common.close")}
|
||||
title={t("common.close")}
|
||||
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -9,22 +18,34 @@ import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
User,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
|
||||
import {
|
||||
parseChartTokens,
|
||||
stripChartTokens,
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
|
||||
import { SourceChips } from "./source-chips";
|
||||
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
|
||||
import { SuggestedActionCard } from "./suggested-action-card";
|
||||
import { StreamedProse } from "./streamed-prose";
|
||||
import { MessageTokenFooter } from "./message-token-footer";
|
||||
import type {
|
||||
@@ -32,7 +53,10 @@ import type {
|
||||
CoachOptimisticUserMessage,
|
||||
CoachStreamingMessage,
|
||||
} from "./use-coach";
|
||||
import type { CoachMessageDTO } from "@/lib/ai/coach/types";
|
||||
import type {
|
||||
CoachMessageDTO,
|
||||
CoachProvenanceMetric,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B2b — message-thread renderer.
|
||||
@@ -209,6 +233,285 @@ export function errorCodeToI18nKey(code: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — Coach accompanying charts, Phase 1: the renderer half.
|
||||
*
|
||||
* The chart-token mechanism (`chart-tokens.ts`) was prepared for the
|
||||
* Insights prose but its render path was never wired for the Coach —
|
||||
* `stripChartTokens` cleaned tokens out of the prose, but `parseChartTokens`
|
||||
* had no caller, so a `metric:<TYPE>` token the model emitted rendered no
|
||||
* chart. This map activates the render half for the Coach.
|
||||
*
|
||||
* Each entry pairs an allowlisted chart token with the Coach provenance
|
||||
* TOPIC that the snapshot stamps when it actually drew on that metric. A
|
||||
* chart only renders when its topic is present in `metricSource.metrics`, so
|
||||
* the series is grounded twice over: the closed allowlist drops a
|
||||
* hallucinated token, and the provenance intersect drops a metric the turn
|
||||
* never saw (and which therefore has no data). The chart itself self-fetches
|
||||
* the user's real series from `/api/measurements` — the model never emits a
|
||||
* data point.
|
||||
*
|
||||
* Only MeasurementType-backed tokens that render through the generic,
|
||||
* self-fetching `<HealthChart>` are listed. The synthetic `metric:MOOD`
|
||||
* token (served by a separate `<MoodChart>`) and the allowlist's reserved
|
||||
* score classes are intentionally omitted from Phase 1.
|
||||
*
|
||||
* The prompt clause that tells the model it MAY emit one such token is a
|
||||
* separate concern (the narrative/prompt workstream); this is render-only,
|
||||
* provider-agnostic (it reads the plain inline token, so it works for every
|
||||
* provider including the codex inline-text path), and a graceful no-op when
|
||||
* no grounded token is present.
|
||||
*/
|
||||
const CHART_TOKEN_PROVENANCE: Partial<
|
||||
Record<ChartToken, CoachProvenanceMetric>
|
||||
> = {
|
||||
"metric:WEIGHT": "weight",
|
||||
"metric:BLOOD_PRESSURE_SYS": "bp",
|
||||
"metric:BLOOD_PRESSURE_DIA": "bp",
|
||||
"metric:PULSE": "pulse",
|
||||
"metric:BODY_FAT": "body_fat",
|
||||
"metric:SLEEP_DURATION": "sleep",
|
||||
"metric:ACTIVITY_STEPS": "steps",
|
||||
"metric:BLOOD_GLUCOSE": "glucose",
|
||||
"metric:TOTAL_BODY_WATER": "total_body_water",
|
||||
"metric:BONE_MASS": "bone_mass",
|
||||
"metric:OXYGEN_SATURATION": "spo2",
|
||||
"metric:HEART_RATE_VARIABILITY": "hrv",
|
||||
"metric:RESTING_HEART_RATE": "resting_hr",
|
||||
"metric:ACTIVE_ENERGY_BURNED": "active_energy",
|
||||
"metric:FLIGHTS_CLIMBED": "flights",
|
||||
"metric:WALKING_RUNNING_DISTANCE": "distance",
|
||||
"metric:VO2_MAX": "vo2_max",
|
||||
"metric:BODY_TEMPERATURE": "body_temp",
|
||||
"metric:FAT_FREE_MASS": "fat_free_mass",
|
||||
"metric:FAT_MASS": "fat_mass",
|
||||
"metric:MUSCLE_MASS": "muscle_mass",
|
||||
"metric:LEAN_BODY_MASS": "lean_body_mass",
|
||||
"metric:BODY_MASS_INDEX": "bmi",
|
||||
"metric:VISCERAL_FAT": "visceral_fat",
|
||||
"metric:SKIN_TEMPERATURE": "skin_temp",
|
||||
"metric:RESPIRATORY_RATE": "respiratory_rate",
|
||||
"metric:PULSE_WAVE_VELOCITY": "pulse_wave_velocity",
|
||||
"metric:VASCULAR_AGE": "vascular_age",
|
||||
"metric:WALKING_HEART_RATE_AVERAGE": "walking_hr",
|
||||
"metric:WALKING_ASYMMETRY": "walking_asymmetry",
|
||||
"metric:WALKING_DOUBLE_SUPPORT": "walking_double_support",
|
||||
"metric:WALKING_STEP_LENGTH": "walking_step_length",
|
||||
"metric:WALKING_SPEED": "walking_speed",
|
||||
"metric:AUDIO_EXPOSURE_ENV": "audio_env",
|
||||
"metric:AUDIO_EXPOSURE_HEADPHONE": "audio_headphone",
|
||||
"metric:AUDIO_EXPOSURE_EVENT": "audio_event",
|
||||
"metric:TIME_IN_DAYLIGHT": "daylight",
|
||||
};
|
||||
|
||||
/** Max charts rendered under a single Coach turn (keeps the reply scannable). */
|
||||
const MAX_COACH_CHARTS = 2;
|
||||
|
||||
/**
|
||||
* Pure selection of the chart tokens to render under an assistant turn:
|
||||
* allowlist-parsed, intersected with the turn's grounded provenance topics,
|
||||
* de-duplicated by metric, and capped. Exported for unit tests so the
|
||||
* grounding contract is pinned without standing up the chart component.
|
||||
*/
|
||||
export function selectCoachChartTokens(
|
||||
content: string,
|
||||
metrics: readonly CoachProvenanceMetric[] | undefined,
|
||||
): ChartToken[] {
|
||||
const grounded = new Set(metrics ?? []);
|
||||
const out: ChartToken[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of parseChartTokens(content)) {
|
||||
const topic = CHART_TOKEN_PROVENANCE[token];
|
||||
if (!topic || !grounded.has(topic)) continue;
|
||||
const metric = tokenToMetric(token);
|
||||
if (seen.has(metric)) continue;
|
||||
seen.add(metric);
|
||||
out.push(token);
|
||||
if (out.length >= MAX_COACH_CHARTS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart tokens whose canonical measurement-type label has no dedicated
|
||||
* `measurements.type*` key — route them to the closest existing key so the
|
||||
* chart header reads cleanly rather than echoing the raw enum.
|
||||
*/
|
||||
const CHART_TITLE_KEY_OVERRIDE: Record<string, string> = {
|
||||
BLOOD_PRESSURE_SYS: "measurements.typeBloodPressure",
|
||||
BLOOD_PRESSURE_DIA: "measurements.typeBloodPressure",
|
||||
};
|
||||
|
||||
/** Localised chart header for a MeasurementType, mirroring the snapshot's
|
||||
* `measurements.type<Camel>` convention with a readable fallback. */
|
||||
function coachChartTitle(
|
||||
metric: string,
|
||||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const override = CHART_TITLE_KEY_OVERRIDE[metric];
|
||||
if (override) {
|
||||
const resolved = t(override);
|
||||
if (resolved !== override) return resolved;
|
||||
}
|
||||
const camel = metric
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((part, i) =>
|
||||
i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join("");
|
||||
const key = `measurements.type${camel.charAt(0).toUpperCase()}${camel.slice(1)}`;
|
||||
const resolved = t(key);
|
||||
return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe.
|
||||
* `useSyncExternalStore` returns false on the server + first client paint
|
||||
* (so the read-aloud button is absent in markup) and resolves to the real
|
||||
* capability after hydration, mirroring the mic button's detection pattern
|
||||
* — no hydration mismatch warning, no `connect-src` (it runs in-browser).
|
||||
*/
|
||||
function useSpeechSynthesisSupported(): boolean {
|
||||
return useSyncExternalStore(
|
||||
() => () => {},
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
"speechSynthesis" in window &&
|
||||
"SpeechSynthesisUtterance" in window,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — read the assistant prose aloud via the browser's
|
||||
* `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A
|
||||
* single in-flight utterance: a new `speak` cancels any prior one; `toggle`
|
||||
* stops while speaking. The utterance language follows the active UI locale
|
||||
* so the browser picks the closest installed voice (best-effort by OS).
|
||||
*/
|
||||
function useReadAloud(): {
|
||||
supported: boolean;
|
||||
speaking: boolean;
|
||||
toggle: (text: string, lang: string) => void;
|
||||
} {
|
||||
const supported = useSpeechSynthesisSupported();
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
|
||||
// Stop any in-flight utterance if the bubble unmounts (thread reset, nav).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== "undefined" && "speechSynthesis" in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(
|
||||
(text: string, lang: string) => {
|
||||
if (!supported) return;
|
||||
const synth = window.speechSynthesis;
|
||||
if (speaking) {
|
||||
synth.cancel();
|
||||
setSpeaking(false);
|
||||
return;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
synth.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(trimmed);
|
||||
utterance.lang = lang;
|
||||
utterance.onend = () => setSpeaking(false);
|
||||
utterance.onerror = () => setSpeaking(false);
|
||||
setSpeaking(true);
|
||||
synth.speak(utterance);
|
||||
},
|
||||
[supported, speaking],
|
||||
);
|
||||
|
||||
return { supported, speaking, toggle };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when
|
||||
* the browser has no Speech Synthesis support. Speaks `stripChartTokens`
|
||||
* (the same text the prose shows), so stray tokens are never voiced.
|
||||
*/
|
||||
function ReadAloudButton({ content }: { content: string }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { supported, speaking, toggle } = useReadAloud();
|
||||
if (!supported) return null;
|
||||
const label = speaking
|
||||
? t("insights.coach.readAloudStop")
|
||||
: t("insights.coach.readAloud");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-read-aloud"
|
||||
onClick={() => toggle(stripChartTokens(content), locale)}
|
||||
aria-label={label}
|
||||
aria-pressed={speaking}
|
||||
title={label}
|
||||
// Interactive bubble affordances share one tap convention:
|
||||
// `min-h-11 sm:min-h-9` (44px mobile floor → 36px desktop), matching the
|
||||
// feedback buttons below. Keep TTS / remember / feedback in lockstep.
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs outline-none focus-visible:ring-2 sm:min-h-9"
|
||||
>
|
||||
{speaking ? (
|
||||
<VolumeX className="size-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Volume2 className="size-3" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small,
|
||||
* calm clock affordance; the locale-aware date + time surfaces in a tooltip
|
||||
* on hover or keyboard focus (desktop) and on tap (mobile, toggled state).
|
||||
* Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe.
|
||||
*/
|
||||
function BubbleTimestamp({
|
||||
iso,
|
||||
align = "start",
|
||||
}: {
|
||||
iso: string;
|
||||
align?: "start" | "end";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = formatters.dateTime(iso);
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-bubble-timestamp"
|
||||
aria-label={t("insights.coach.messageTimeLabel", { time: label })}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setOpen(false)}
|
||||
className="peer text-muted-foreground/70 hover:text-muted-foreground focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded outline-none focus-visible:ring-2 sm:size-8"
|
||||
>
|
||||
<Clock className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground border-border pointer-events-none absolute bottom-full z-50 mb-1",
|
||||
"rounded-md border px-2 py-1 text-xs whitespace-nowrap shadow-md",
|
||||
"opacity-0 transition-opacity duration-100 peer-hover:opacity-100 peer-focus-visible:opacity-100",
|
||||
"motion-reduce:transition-none",
|
||||
open && "opacity-100",
|
||||
align === "end" ? "right-0" : "left-0",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageThread({
|
||||
conversation,
|
||||
streaming,
|
||||
@@ -425,6 +728,7 @@ export function MessageThread({
|
||||
messageId={m.id}
|
||||
tokensUsed={m.tokensUsed}
|
||||
model={m.model}
|
||||
createdAt={m.createdAt}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
@@ -466,6 +770,7 @@ export function MessageThread({
|
||||
content={streaming.content}
|
||||
metricSource={streaming.metricSource}
|
||||
suggestion={streaming.suggestion}
|
||||
suggestedAction={streaming.suggestedAction}
|
||||
providerType={streaming.inProgress ? "streaming" : null}
|
||||
inProgress={streaming.inProgress}
|
||||
errorCode={streaming.errorCode}
|
||||
@@ -494,6 +799,14 @@ interface ChatBubbleProps {
|
||||
* instead; the bubble falls back to that so the card survives reload.
|
||||
*/
|
||||
suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null;
|
||||
/**
|
||||
* v1.22 (W7/W6) — live generalised confirm-card action from the streaming
|
||||
* hook. Persisted messages carry it on `metricSource.suggestedAction`; the
|
||||
* bubble falls back to that so the card survives reload.
|
||||
*/
|
||||
suggestedAction?:
|
||||
| import("@/lib/ai/coach/suggest-action").CoachSuggestedAction
|
||||
| null;
|
||||
providerType?: string | null;
|
||||
inProgress?: boolean;
|
||||
errorCode?: string | null;
|
||||
@@ -521,6 +834,12 @@ interface ChatBubbleProps {
|
||||
*/
|
||||
tokensUsed?: number | null;
|
||||
model?: string | null;
|
||||
/**
|
||||
* v1.22 (W5) — ISO creation timestamp for the persisted bubble's
|
||||
* hover/tap timestamp tooltip. Absent on optimistic + streaming bubbles
|
||||
* (no persisted time yet), so those render no timestamp.
|
||||
*/
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
function ChatBubble({
|
||||
@@ -528,6 +847,7 @@ function ChatBubble({
|
||||
content,
|
||||
metricSource,
|
||||
suggestion,
|
||||
suggestedAction,
|
||||
providerType,
|
||||
inProgress,
|
||||
errorCode,
|
||||
@@ -536,6 +856,7 @@ function ChatBubble({
|
||||
usage,
|
||||
tokensUsed,
|
||||
model,
|
||||
createdAt,
|
||||
}: ChatBubbleProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -580,8 +901,12 @@ function ChatBubble({
|
||||
"text-sm leading-relaxed",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
{/* v1.22 (W5) — render real paragraph blocks so a multi-line
|
||||
message reads as paragraphs, not one run-on block. User text
|
||||
is verbatim: no chart-token strip, no Learn linkify. */}
|
||||
<ProseBlocks text={content} strip={false} linkify={false} />
|
||||
</div>
|
||||
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
|
||||
{/* 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
|
||||
@@ -607,7 +932,7 @@ function ChatBubble({
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="coach-bubble-user-avatar"
|
||||
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold"
|
||||
className="text-muted-foreground bg-muted/60 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold"
|
||||
>
|
||||
{initials ?? <User className="size-3.5" />}
|
||||
</div>
|
||||
@@ -645,6 +970,15 @@ function ChatBubble({
|
||||
(metricSource.windows?.length ?? 0) > 0);
|
||||
const hasProvenance = hasChips || keyValues.length > 0;
|
||||
|
||||
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
|
||||
// grounded `metric:<TYPE>` chart under a SETTLED assistant turn. Skipped
|
||||
// while streaming / in-flight / errored / on a refusal; a no-op when no
|
||||
// grounded token is present (provider-agnostic — reads the inline token).
|
||||
const chartTokens =
|
||||
!streaming && !inProgress && !errorCode && providerType !== "refusal"
|
||||
? selectCoachChartTokens(content, metricSource?.metrics)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-bubble-assistant"
|
||||
@@ -699,6 +1033,30 @@ function ChatBubble({
|
||||
{safeError && content && (
|
||||
<p className="text-warning/90 text-xs">{safeError}</p>
|
||||
)}
|
||||
{/* v1.22 (W5) — accompanying chart(s). The token was already
|
||||
stripped from the prose by <StreamedProse>; here it mounts the
|
||||
real, self-fetching Recharts chart for the user's own series.
|
||||
The model never emits data — only a metric identifier from a
|
||||
closed allowlist, intersected with the turn's grounded metrics. */}
|
||||
{chartTokens.length > 0 && (
|
||||
<div data-slot="coach-charts" className="flex w-full flex-col gap-3">
|
||||
{chartTokens.map((token) => {
|
||||
const metric = tokenToMetric(token);
|
||||
return (
|
||||
<div
|
||||
key={token}
|
||||
data-slot="coach-chart"
|
||||
className="border-border/60 bg-muted/20 rounded-xl border p-2"
|
||||
>
|
||||
<HealthChartDynamic
|
||||
types={[metric]}
|
||||
title={coachChartTitle(metric, t)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* v1.18.6 — a "no provider configured anywhere" turn is a
|
||||
setup gap, not a transient failure: surface a direct link to
|
||||
Settings → AI so the Coach guides the user into BYOK / local
|
||||
@@ -817,6 +1175,16 @@ function ChatBubble({
|
||||
const sug = suggestion ?? metricSource?.suggestion ?? null;
|
||||
return sug ? <ReminderSuggestionCard suggestion={sug} /> : null;
|
||||
})()}
|
||||
{/* v1.22 (W7/W6) — generalised confirm-card action. Live from the
|
||||
streaming hook, or restored from persisted message provenance on
|
||||
reload. Mirrors the reminder-suggestion block above. */}
|
||||
{!inProgress &&
|
||||
!errorCode &&
|
||||
(() => {
|
||||
const action =
|
||||
suggestedAction ?? metricSource?.suggestedAction ?? null;
|
||||
return action ? <SuggestedActionCard action={action} /> : null;
|
||||
})()}
|
||||
{/* v1.18.9 — quiet per-message token footer. The just-finished
|
||||
streaming turn reads the `done.usage` envelope; a persisted /
|
||||
reloaded turn reads the message's own `tokensUsed` + `model`.
|
||||
@@ -829,14 +1197,19 @@ function ChatBubble({
|
||||
)}
|
||||
{/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
|
||||
assistant messages get the row (skipped for refusals,
|
||||
errors, in-flight stream bubbles). The aggregator buckets
|
||||
the rating by (promptVersion, tone, verbosity). */}
|
||||
{messageId &&
|
||||
!inProgress &&
|
||||
!errorCode &&
|
||||
providerType !== "refusal" && (
|
||||
<CoachMessageFeedback messageId={messageId} />
|
||||
)}
|
||||
errors, in-flight stream bubbles). v1.22 (W5) — the
|
||||
read-aloud toggle joins the same action row on a settled turn;
|
||||
the bubble timestamp trails it. */}
|
||||
{!inProgress && !errorCode && providerType !== "refusal" && content && (
|
||||
<div
|
||||
data-slot="coach-bubble-actions"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{!streaming && <ReadAloudButton content={content} />}
|
||||
{messageId && <CoachMessageFeedback messageId={messageId} />}
|
||||
{createdAt && <BubbleTimestamp iso={createdAt} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -908,7 +1281,7 @@ function RememberUserMessage({ content }: { content: string }) {
|
||||
disabled={remember.isPending}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs",
|
||||
"inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs sm:min-h-9",
|
||||
"outline-none focus-visible:ring-2 disabled:opacity-50",
|
||||
// Calmer thread on pointer devices: the control stays invisible
|
||||
// until its bubble is hovered or holds focus. Touch viewports
|
||||
|
||||
@@ -4,22 +4,37 @@ import { useMemo } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
ProseBlocks,
|
||||
ParagraphText,
|
||||
splitParagraphs,
|
||||
PROSE_PARAGRAPH_CLASS,
|
||||
} from "@/components/insights/prose-blocks";
|
||||
|
||||
/**
|
||||
* v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn.
|
||||
*
|
||||
* The provider clients return the whole reply in one shot; the chat route
|
||||
* re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`),
|
||||
* so `content` grows as frames land. This component renders each
|
||||
* whitespace segment as its own `<span>` that fades + drifts up on mount,
|
||||
* giving the reply a "thinking out loud" cadence rather than a hard dump
|
||||
* of word groups. Once the turn settles (`streaming === false`) the spans
|
||||
* collapse to a single plain-text node — zero residual animation DOM, and
|
||||
* the feedback / evidence layout underneath stays stable.
|
||||
* so `content` grows as frames land. While the turn is streaming each
|
||||
* whitespace segment of the GROWING tail renders as its own `<span>` that
|
||||
* fades + drifts up on mount, giving the reply a "thinking out loud"
|
||||
* cadence rather than a hard dump of word groups.
|
||||
*
|
||||
* Why no "only animate the new words" bookkeeping: each segment is keyed
|
||||
* by its index, so when a fresh SSE frame appends words, React keeps the
|
||||
* already-mounted spans (their CSS entrance already ran and does not
|
||||
* v1.22 (W5) — paragraph-aware + SSE-safe. The reply now renders as real
|
||||
* `<p>` blocks (shared `ProseBlocks`), so a model that emits blank-line
|
||||
* paragraph breaks reads as paragraphs, not one run-on block — consistent
|
||||
* with the daily briefing + insight cards. Under streaming, every COMPLETE
|
||||
* paragraph settles immediately (plain text, with the safe Learn linkifier)
|
||||
* and only the LAST, still-growing paragraph animates word-by-word. When a
|
||||
* fresh `\n\n` arrives the tail becomes a completed paragraph and stops
|
||||
* animating; the already-settled paragraphs keep their index keys and never
|
||||
* replay. Once the whole turn settles (`streaming === false`) it collapses
|
||||
* to the same `ProseBlocks` render the persisted history uses.
|
||||
*
|
||||
* Why no "only animate the new words" bookkeeping: each tail segment is
|
||||
* keyed by its index, so when a fresh SSE frame appends words React keeps
|
||||
* the already-mounted spans (their CSS entrance already ran and does not
|
||||
* replay on reconcile) and only MOUNTS the new trailing spans — which is
|
||||
* exactly when the `animate-in` entrance fires. The browser does the
|
||||
* "new words only" gating for us; no render-time ref is needed.
|
||||
@@ -30,8 +45,8 @@ import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
* attributes are already permitted under the strict CSP
|
||||
* (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected
|
||||
* `<style>` tag, no `dangerouslySetInnerHTML`, and no markdown library —
|
||||
* the text renders as plain React children, the XSS-safe path the rest of
|
||||
* the Coach uses.
|
||||
* the text renders as plain React children (with a closed-set Learn
|
||||
* linkifier), the XSS-safe path the rest of the Coach uses.
|
||||
*
|
||||
* `prefers-reduced-motion`: the animation classes are `motion-safe:`
|
||||
* gated, so reduced-motion users get the words instantly with no fade and
|
||||
@@ -42,7 +57,7 @@ export interface StreamedProseProps {
|
||||
content: string;
|
||||
/**
|
||||
* True while the turn is still streaming. When false the prose renders
|
||||
* settled as one plain-text node (persisted history + finished turns).
|
||||
* settled as real paragraph blocks (persisted history + finished turns).
|
||||
*/
|
||||
streaming: boolean;
|
||||
}
|
||||
@@ -57,40 +72,64 @@ export function splitProseSegments(text: string): string[] {
|
||||
}
|
||||
|
||||
export function StreamedProse({ content, streaming }: StreamedProseProps) {
|
||||
const clean = stripChartTokens(content);
|
||||
const segments = useMemo(
|
||||
() => (streaming ? splitProseSegments(clean) : []),
|
||||
[streaming, clean],
|
||||
// Split paragraphs on the RAW text — `stripChartTokens` normalises
|
||||
// whitespace (collapsing the `\n\n` breaks), so it must run per line
|
||||
// inside the renderers, never on the whole string before the split.
|
||||
const paras = useMemo(
|
||||
() => (streaming ? splitParagraphs(content) : []),
|
||||
[streaming, content],
|
||||
);
|
||||
|
||||
if (!streaming) {
|
||||
// Settled turn: one plain text node, no spans, no animation.
|
||||
return <>{clean}</>;
|
||||
// Settled turn: real <p> blocks + per-line token strip + the safe Learn
|
||||
// linkifier, the same render the persisted history uses.
|
||||
return <ProseBlocks text={content} />;
|
||||
}
|
||||
|
||||
if (paras.length === 0) return null;
|
||||
|
||||
const tail = paras[paras.length - 1];
|
||||
const settled = paras.slice(0, -1);
|
||||
// The growing tail animates word-by-word; strip its tokens here so a
|
||||
// mid-stream `metric:<TYPE>` leak never flashes into the prose.
|
||||
const tailSegments = splitProseSegments(stripChartTokens(tail));
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (
|
||||
// New trailing spans mount + animate; already-mounted spans keep
|
||||
// their stable index key and do not replay the entrance.
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
"motion-safe:animate-in motion-safe:fade-in-0",
|
||||
"motion-safe:slide-in-from-bottom-1",
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "340ms",
|
||||
// A gentle, capped stagger keyed off the in-batch position so a
|
||||
// burst of words reads as a soft cascade rather than a single
|
||||
// flash, without lagging on a long frame.
|
||||
animationDelay: `${(index % 6) * 26}ms`,
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
{/* Completed paragraphs settle immediately — plain text + linkifier,
|
||||
identical to the non-streaming render so nothing reflows when the
|
||||
turn finishes. */}
|
||||
{settled.map((p, i) => (
|
||||
<p key={i} className={cn(PROSE_PARAGRAPH_CLASS)}>
|
||||
<ParagraphText text={p} strip />
|
||||
</p>
|
||||
))}
|
||||
{/* The growing tail paragraph: word-by-word fade. No linkify here —
|
||||
a half-arrived URL must not flicker into a link mid-stream; it
|
||||
settles (and linkifies) once the turn finishes. */}
|
||||
<p key={settled.length} className={cn(PROSE_PARAGRAPH_CLASS)}>
|
||||
{tailSegments.map((segment, index) => (
|
||||
// New trailing spans mount + animate; already-mounted spans keep
|
||||
// their stable index key and do not replay the entrance.
|
||||
<span
|
||||
key={index}
|
||||
className={cn(
|
||||
"motion-safe:animate-in motion-safe:fade-in-0",
|
||||
"motion-safe:slide-in-from-bottom-1",
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "340ms",
|
||||
// A gentle, capped stagger keyed off the in-batch position so a
|
||||
// burst of words reads as a soft cascade rather than a single
|
||||
// flash, without lagging on a long frame.
|
||||
animationDelay: `${(index % 6) * 26}ms`,
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
|
||||
/**
|
||||
* v1.22 (F6) — the generalised one-tap "confirm → apply" Coach action card.
|
||||
*
|
||||
* Rendered under an assistant bubble when the turn carried a `suggestedAction`
|
||||
* (live from the SSE frame, or persisted on the message provenance). The card
|
||||
* shows a localized heading per action type (`titleKey`) + the model's plain-text
|
||||
* summary, and two actions:
|
||||
*
|
||||
* - "Add" → POSTs the validated params to `POST /api/coach/suggested-actions`,
|
||||
* which builds the entity SERVER-side, field-by-field. NEVER auto-applied.
|
||||
* - "Not now" → dismisses the card locally (no server write).
|
||||
*
|
||||
* RENDER dedup mirrors the cadence card: once settled, the card collapses to a
|
||||
* confirmation so a stale stream + persisted twin can't double-prompt. The
|
||||
* STATIC copy is i18n; the dynamic summary/label is the user's own content
|
||||
* (plain text — no markdown, matching the XSS posture).
|
||||
*/
|
||||
export function SuggestedActionCard({
|
||||
action,
|
||||
}: {
|
||||
action: CoachSuggestedAction;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null);
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/coach/suggested-actions", action.params);
|
||||
},
|
||||
onSuccess: () => setSettled("applied"),
|
||||
onError: () => toast.error(t("coach.suggestedAction.failed")),
|
||||
});
|
||||
|
||||
if (settled) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="coach-suggested-action-done"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
|
||||
{t(
|
||||
settled === "applied"
|
||||
? "coach.suggestedAction.applied"
|
||||
: "coach.suggestedAction.dismissed",
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const busy = apply.isPending;
|
||||
const intervalLabel =
|
||||
action.params.actionType === "checkup.create"
|
||||
? t(`coach.suggestedAction.interval.${action.params.interval}`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-suggested-action-card"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
|
||||
"px-3.5 py-3 text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="from-dracula-purple to-dracula-cyan mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<Sparkles className="text-background size-3.5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t(action.titleKey)}
|
||||
</span>
|
||||
<span className="text-foreground leading-relaxed break-words">
|
||||
{action.summary}
|
||||
</span>
|
||||
{intervalLabel && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{intervalLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-suggested-action-apply"
|
||||
onClick={() => apply.mutate()}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
|
||||
"focus-visible:ring-2 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("coach.suggestedAction.apply")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-suggested-action-dismiss"
|
||||
onClick={() => setSettled("dismissed")}
|
||||
disabled={busy}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50 sm:min-h-9"
|
||||
>
|
||||
{t("coach.suggestedAction.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CoachSuggestion,
|
||||
CoachUsage,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -186,6 +187,12 @@ export interface CoachStreamingMessage {
|
||||
* `suggestion` frame, if the turn carried one. Null otherwise.
|
||||
*/
|
||||
suggestion: CoachSuggestion | null;
|
||||
/**
|
||||
* v1.22 (W7/W6) — generalised confirm-card action from the additive
|
||||
* `suggestedAction` frame (mirrors `suggestion`). Persisted messages carry it
|
||||
* on `metricSource.suggestedAction` instead. Null otherwise.
|
||||
*/
|
||||
suggestedAction: CoachSuggestedAction | null;
|
||||
/** True until the `done` frame closes the stream. */
|
||||
inProgress: boolean;
|
||||
/** Final messageId once `done` lands; null otherwise. */
|
||||
@@ -236,6 +243,7 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
suggestedAction: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
@@ -330,6 +338,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
suggestedAction: null,
|
||||
inProgress: true,
|
||||
messageId: null,
|
||||
errorCode: null,
|
||||
@@ -344,6 +353,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
suggestedAction: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
@@ -378,6 +388,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
suggestedAction: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
@@ -391,6 +402,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
suggestedAction: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: `coach.http.${response.status}`,
|
||||
@@ -429,6 +441,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: "",
|
||||
metricSource: null,
|
||||
suggestion: null,
|
||||
suggestedAction: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: structured ?? `coach.http.${response.status}`,
|
||||
@@ -449,6 +462,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
let collectedContent = "";
|
||||
let collectedProvenance: CoachProvenance | null = null;
|
||||
let collectedSuggestion: CoachSuggestion | null = null;
|
||||
let collectedSuggestedAction: CoachSuggestedAction | null = null;
|
||||
let lastError: string | null = null;
|
||||
let messageId: string | null = null;
|
||||
let collectedUsage: CoachUsage | null = null;
|
||||
@@ -483,6 +497,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
suggestion: evt.suggestion,
|
||||
}));
|
||||
break;
|
||||
case "suggestedAction":
|
||||
collectedSuggestedAction = evt.suggestedAction;
|
||||
setStreaming((prev) => ({
|
||||
...prev,
|
||||
suggestedAction: evt.suggestedAction,
|
||||
}));
|
||||
break;
|
||||
case "reasoning":
|
||||
// v1.19.1 — the thinking disclosure was removed, so the
|
||||
// additive `reasoning` frame is accepted off the wire and
|
||||
@@ -522,6 +543,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
content: collectedContent,
|
||||
metricSource: collectedProvenance,
|
||||
suggestion: collectedSuggestion,
|
||||
suggestedAction: collectedSuggestedAction,
|
||||
inProgress: false,
|
||||
messageId,
|
||||
errorCode: lastError,
|
||||
|
||||
@@ -85,13 +85,11 @@ const COACH_SOURCES_BY_KIND: Record<
|
||||
"weight-weekday": ["weight"],
|
||||
};
|
||||
|
||||
// v1.22 (W6) — i18n keys (resolved at the call site), not hardcoded English.
|
||||
const COACH_QUESTION_BY_KIND: Record<CorrelationResult["kind"], string> = {
|
||||
"bp-compliance":
|
||||
"How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.",
|
||||
"mood-pulse":
|
||||
"Is there a real link between my mood and my pulse? Help me read this correlation.",
|
||||
"weight-weekday":
|
||||
"Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
|
||||
"bp-compliance": "insights.coach.seed.correlationBpCompliance",
|
||||
"mood-pulse": "insights.coach.seed.correlationMoodPulse",
|
||||
"weight-weekday": "insights.coach.seed.correlationWeightWeekday",
|
||||
};
|
||||
|
||||
export function CorrelationCard({ result }: CorrelationCardProps) {
|
||||
@@ -128,7 +126,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
|
||||
data-slot="correlation-card-confidence"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"shrink-0 text-[10px]",
|
||||
"shrink-0 text-xs",
|
||||
CONFIDENCE_BADGE_CLASS[result.confidenceBand.label],
|
||||
)}
|
||||
>
|
||||
@@ -155,7 +153,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
|
||||
</p>
|
||||
<p
|
||||
data-slot="correlation-card-source"
|
||||
className="text-muted-foreground text-[11px]"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.correlationRow.sourceChip", {
|
||||
n: result.n,
|
||||
@@ -183,7 +181,7 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
|
||||
{/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the
|
||||
roadmap CTA: it scopes to both sources in the pair. */}
|
||||
<AskCoachAction
|
||||
question={COACH_QUESTION_BY_KIND[result.kind]}
|
||||
question={t(COACH_QUESTION_BY_KIND[result.kind])}
|
||||
scope={coachScope}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,10 @@ import { ListRow } from "@/components/ui/list-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { AskCoachAction } from "@/components/insights/ask-coach-action";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DailyBriefing as DailyBriefingPayload,
|
||||
@@ -352,6 +353,8 @@ export function DailyBriefing({
|
||||
memory = null,
|
||||
}: DailyBriefingProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
// v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
|
||||
@@ -430,7 +433,7 @@ export function DailyBriefing({
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-signals-title"
|
||||
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
|
||||
className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.signalsTitle")}
|
||||
</p>
|
||||
@@ -448,7 +451,7 @@ export function DailyBriefing({
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-findings-title"
|
||||
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
|
||||
className="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.keyFindingsTitle")}
|
||||
</p>
|
||||
@@ -470,11 +473,15 @@ export function DailyBriefing({
|
||||
{updatedAt && (
|
||||
<p
|
||||
data-slot="daily-briefing-updated"
|
||||
className="text-muted-foreground text-right text-[11px]"
|
||||
className="text-muted-foreground text-right text-xs"
|
||||
>
|
||||
{t("insights.heroGenerated", {
|
||||
time: formatRelativeTime(updatedAt, t),
|
||||
})}
|
||||
{formatUpdatedLabel(
|
||||
updatedAt,
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
user?.timezone,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — a stale briefing that can never refresh
|
||||
@@ -484,7 +491,7 @@ export function DailyBriefing({
|
||||
{noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-stale-no-provider"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-[11px]"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
@@ -504,7 +511,7 @@ export function DailyBriefing({
|
||||
picture. The briefing spans every metric, so no scope: the
|
||||
default all-source snapshot reads best here. */}
|
||||
<div className="flex justify-end">
|
||||
<AskCoachAction question="Walk me through today's briefing — what should I focus on, and is anything worth acting on?" />
|
||||
<AskCoachAction question={t("insights.coach.seed.briefing")} />
|
||||
</div>
|
||||
</div>
|
||||
) : noProvider ? (
|
||||
|
||||
@@ -26,7 +26,7 @@ const EMPTY_PROVENANCE: DerivedProvenance = {
|
||||
};
|
||||
|
||||
describe("<ProvenanceExplainer>", () => {
|
||||
it("renders an accessible trigger that controls the body (collapsed by default)", () => {
|
||||
it("renders the method inline as a muted caption (no trigger glyph)", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={PROVENANCE}
|
||||
@@ -34,28 +34,21 @@ describe("<ProvenanceExplainer>", () => {
|
||||
bodyId="prov-1"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('aria-controls="prov-1"');
|
||||
expect(html).toContain("aria-label");
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
expect(html).toContain("BMI is weight divided by height squared.");
|
||||
expect(html).toContain('id="prov-1"');
|
||||
// The old icon-only disclosure trigger is gone.
|
||||
expect(html).not.toContain('data-slot="provenance-explainer-trigger"');
|
||||
});
|
||||
|
||||
it("meets the 44px hit-target floor on the trigger", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer provenance={PROVENANCE} method="method copy" />,
|
||||
);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
});
|
||||
|
||||
it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => {
|
||||
it("renders the empty-inputs / bad-date provenance without throwing", () => {
|
||||
const html = render(
|
||||
<ProvenanceExplainer
|
||||
provenance={EMPTY_PROVENANCE}
|
||||
method="method copy"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
});
|
||||
|
||||
it("accepts a cited standard prop without erroring", () => {
|
||||
@@ -69,6 +62,6 @@ describe("<ProvenanceExplainer>", () => {
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
});
|
||||
});
|
||||
@@ -55,9 +55,9 @@ describe("<ScoreAnatomyView>", () => {
|
||||
// The missing contributor renders present=false (dropped, not blank).
|
||||
expect(html).toContain('data-contributor="respiratory"');
|
||||
expect(html).toContain('data-present="false"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-trigger"');
|
||||
expect(html).toContain('data-slot="provenance-explainer-method"');
|
||||
// v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed;
|
||||
// the non-clinical framing now lives behind the provenance explainer.
|
||||
// the non-clinical framing now lives in the inline provenance method.
|
||||
expect(html).not.toContain('data-slot="score-anatomy-disclaimer"');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
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";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the "how this is computed / standard cited" surface.
|
||||
* v1.10.0 — the "how this is computed" surface.
|
||||
*
|
||||
* A collapsible `ⓘ` explainer that renders, as React text children (NO
|
||||
* markdown library — the XSS rule), the metric's inputs, a plain-language
|
||||
* method, and an optional link to the cited standard (IEEE / RFC / WHO /
|
||||
* LOINC / Wikipedia). Reads the `provenance` field off `Derived<T>` for the
|
||||
* inputs chip + the "as of" + window framing.
|
||||
*
|
||||
* Surface follows the shipped `health-score-delta-explainer` pattern:
|
||||
* Popover on `md+`, bottom-sheet on phone-class viewports via
|
||||
* `ResponsiveSheet`. Owns its own open state. aria-describedby threads the
|
||||
* trigger to the body.
|
||||
* v1.22 — the collapsible `ⓘ` glyph that gated the method/inputs/window read
|
||||
* behind a popover / bottom-sheet is removed; the maintainer wants the
|
||||
* trailing info affordance gone across the insights surface. The
|
||||
* plain-language method now renders inline as a muted caption (React text
|
||||
* children only — the XSS rule still forbids a markdown library), so the
|
||||
* "how computed" line is always visible rather than disclosure-only. The
|
||||
* `provenance`, `standard` and `title` props are retained for callers; the
|
||||
* inputs-chip / window / cited-standard detail folded out with the popover.
|
||||
*/
|
||||
|
||||
export interface ProvenanceStandard {
|
||||
@@ -43,167 +31,28 @@ export interface ProvenanceExplainerProps {
|
||||
* passes a localised string; the component never parses markup.
|
||||
*/
|
||||
method: React.ReactNode;
|
||||
/** Optional cited standard rendered as an external link in the footer. */
|
||||
/** Optional cited standard. Retained for callers. */
|
||||
standard?: ProvenanceStandard;
|
||||
/** Accessible title of the surface (sheet header / popover first line). */
|
||||
/** Accessible title of the surface. Retained for callers. */
|
||||
title?: string;
|
||||
/** Optional className for the trigger button. */
|
||||
/** Optional className for the caption. */
|
||||
className?: string;
|
||||
/** Optional id the parent threads as aria-describedby on its own element. */
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
export function ProvenanceExplainer({
|
||||
provenance,
|
||||
method,
|
||||
standard,
|
||||
title,
|
||||
className,
|
||||
bodyId,
|
||||
}: ProvenanceExplainerProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const generatedId = useId();
|
||||
const resolvedBodyId = bodyId ?? generatedId;
|
||||
|
||||
const resolvedTitle = title ?? t("insights.derived.provenance.title");
|
||||
const triggerLabel = t("insights.derived.provenance.trigger");
|
||||
|
||||
const asOf = (() => {
|
||||
const parsed = new Date(provenance.computedAt);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
try {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
}).format(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const body = (
|
||||
<div
|
||||
id={resolvedBodyId}
|
||||
data-slot="provenance-explainer-body"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div data-slot="provenance-explainer-inputs">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.inputsLabel")}
|
||||
</p>
|
||||
{provenance.inputs.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{provenance.inputs.map((input) => (
|
||||
<li
|
||||
key={input}
|
||||
data-slot="provenance-explainer-input-chip"
|
||||
className="border-info/40 text-info inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none"
|
||||
>
|
||||
{input}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-1 text-[11px]">
|
||||
{t("insights.derived.provenance.noInputs")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-slot="provenance-explainer-method">
|
||||
<p className="text-foreground text-xs font-semibold">
|
||||
{t("insights.derived.provenance.methodLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-[11px] leading-snug">
|
||||
{method}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-slot="provenance-explainer-window"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{t("insights.derived.provenance.window", {
|
||||
days: provenance.windowDays,
|
||||
source: provenance.source,
|
||||
})}
|
||||
{asOf
|
||||
? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{standard && (
|
||||
<a
|
||||
data-slot="provenance-explainer-standard-link"
|
||||
href={standard.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.derived.provenance.standardLink", {
|
||||
name: standard.name,
|
||||
})}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="provenance-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={resolvedBodyId}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={resolvedTitle}
|
||||
>
|
||||
<div className="text-sm">{body}</div>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="provenance-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-2"
|
||||
>
|
||||
<p
|
||||
data-slot="provenance-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{resolvedTitle}
|
||||
</p>
|
||||
{body}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span
|
||||
id={bodyId}
|
||||
data-slot="provenance-explainer-method"
|
||||
className={cn("text-muted-foreground text-xs leading-snug", className)}
|
||||
>
|
||||
{method}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -729,6 +729,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
<SectionHeading
|
||||
icon={HeartPulse}
|
||||
title={t("insights.derived.vitals.sectionTitle")}
|
||||
subtitle={t("insights.derived.vitals.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="vitals-dashboard-error"
|
||||
@@ -774,6 +775,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
<SectionHeading
|
||||
icon={HeartPulse}
|
||||
title={t("insights.derived.vitals.sectionTitle")}
|
||||
subtitle={t("insights.derived.vitals.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
|
||||
@@ -211,6 +211,7 @@ export function WellnessScores({
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.scores.sectionTitle")}
|
||||
subtitle={t("insights.derived.scores.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="wellness-scores-error"
|
||||
@@ -394,6 +395,7 @@ export function WellnessScores({
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.scores.sectionTitle")}
|
||||
subtitle={t("insights.derived.scores.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="wellness-scores-grid"
|
||||
|
||||
@@ -341,7 +341,7 @@ export function HealthScoreCard({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p
|
||||
data-slot="health-score-card-label"
|
||||
className="text-muted-foreground text-[10px] font-semibold tracking-[0.18em] uppercase"
|
||||
className="text-muted-foreground text-xs font-semibold tracking-[0.18em] uppercase"
|
||||
>
|
||||
{t("insights.healthScore.label")}
|
||||
</p>
|
||||
@@ -361,7 +361,7 @@ export function HealthScoreCard({
|
||||
{delta !== null && delta > 0 && (
|
||||
<span
|
||||
data-slot="health-score-card-delta-chip"
|
||||
className="bg-success/15 text-success rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
className="bg-success/15 text-success rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
>
|
||||
+{delta}
|
||||
</span>
|
||||
@@ -414,7 +414,7 @@ export function HealthScoreCard({
|
||||
branch keeps the existing single-line caption. */}
|
||||
<p
|
||||
data-slot="health-score-card-delta"
|
||||
className="text-muted-foreground inline-flex items-center gap-1 text-[11px]"
|
||||
className="text-muted-foreground inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
{delta === null ? (
|
||||
<span>{t("insights.healthScore.deltaUnavailable")}</span>
|
||||
@@ -456,7 +456,7 @@ export function HealthScoreCard({
|
||||
{restModeActive && (
|
||||
<p
|
||||
data-slot="health-score-card-rest-mode"
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-xs leading-relaxed"
|
||||
>
|
||||
<Moon className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{t("insights.healthScore.restModePaused")}</span>
|
||||
@@ -474,7 +474,7 @@ export function HealthScoreCard({
|
||||
<p
|
||||
data-slot="health-score-card-tension"
|
||||
data-band={tension.band}
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
className="text-muted-foreground inline-flex items-start gap-1.5 text-xs leading-relaxed"
|
||||
>
|
||||
<Scale className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
@@ -493,7 +493,7 @@ export function HealthScoreCard({
|
||||
{returnToBand && (
|
||||
<p
|
||||
data-slot="health-score-card-return-to-band"
|
||||
className="text-success/90 inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
|
||||
className="text-success/90 inline-flex items-start gap-1.5 text-xs leading-relaxed"
|
||||
>
|
||||
<CornerDownLeft
|
||||
className="mt-0.5 size-3 shrink-0"
|
||||
@@ -576,7 +576,7 @@ export function HealthScoreCard({
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"flex w-full items-center justify-between gap-1 rounded text-[11px]",
|
||||
"flex w-full items-center justify-between gap-1 rounded text-xs",
|
||||
)}
|
||||
>
|
||||
<span>{t("insights.healthScore.provenance.toggle")}</span>
|
||||
@@ -600,7 +600,7 @@ export function HealthScoreCard({
|
||||
<p
|
||||
data-slot="health-score-card-provenance-mixed-banner"
|
||||
role="status"
|
||||
className="border-warning/30 text-warning/90 bg-warning/5 rounded border px-2 py-1 text-[10px] leading-snug"
|
||||
className="border-warning/30 text-warning/90 bg-warning/5 rounded border px-2 py-1 text-xs leading-snug"
|
||||
>
|
||||
{t("insights.healthScore.provenance.mixedBanner")}
|
||||
</p>
|
||||
@@ -625,7 +625,7 @@ export function HealthScoreCard({
|
||||
data-component={row.key}
|
||||
data-source={row.source}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-[11px]",
|
||||
"flex items-center gap-2 text-xs",
|
||||
isEmpty && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -684,7 +684,7 @@ export function HealthScoreCard({
|
||||
title={asOfLine ?? undefined}
|
||||
data-as-of={row.asOf ?? undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-[10px] leading-none",
|
||||
"inline-flex items-center rounded-full border bg-transparent px-1.5 py-0.5 text-xs leading-none",
|
||||
SOURCE_PILL_CLASS[row.source],
|
||||
)}
|
||||
>
|
||||
@@ -696,7 +696,7 @@ export function HealthScoreCard({
|
||||
</ul>
|
||||
<p
|
||||
data-slot="health-score-card-provenance-footnote"
|
||||
className="text-muted-foreground text-[10px] leading-snug"
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
>
|
||||
{t("insights.healthScore.provenance.footnote")}
|
||||
</p>
|
||||
@@ -714,7 +714,7 @@ export function HealthScoreCard({
|
||||
a single discreet entry, not a primary CTA. */}
|
||||
<div className="mt-auto flex justify-end pt-1">
|
||||
<AskCoachAction
|
||||
question={`Why is my health score ${score} out of 100, and what would move it the most?`}
|
||||
question={t("insights.coach.seed.healthScore", { score })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,167 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useId } from "react";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — explain the "vs last week" delta on tap.
|
||||
* v1.4.28 R3c-Insights — explain the "vs last week" delta.
|
||||
*
|
||||
* FB-I1: the user asked why the headline number moved. The earlier
|
||||
* delta line surfaced the digit ("-3 vs last week") without any
|
||||
* mention of which components shifted, what the comparison window
|
||||
* actually is, or what the user can do about it. The explainer is a
|
||||
* single icon-only `?` button next to the delta line that opens a
|
||||
* three-sentence read.
|
||||
* v1.22 — the icon-only `?` glyph that gated the three-sentence read behind
|
||||
* a popover / bottom-sheet is removed; the maintainer wants the trailing
|
||||
* help affordance gone across the insights surface. The descriptive read
|
||||
* now renders inline as a muted caption beside the delta line, so it is
|
||||
* always visible rather than disclosure-only. The body keeps its id so the
|
||||
* parent's `aria-describedby` on the delta digit still threads to it.
|
||||
*
|
||||
* Surface choice follows R1.1 §1 — popover on `md+`, bottom-sheet
|
||||
* on phone-class viewports. Both share the same body copy via the
|
||||
* `insights.healthScore.deltaExplainer.body` key; the title is
|
||||
* surfaced as the popover-content first line on desktop and as the
|
||||
* sheet header on mobile (the ResponsiveSheet primitive already
|
||||
* paints a header band on the sheet branch).
|
||||
*
|
||||
* Pure presentational. Owns its own open state — the parent doesn't
|
||||
* need to thread an extra prop through.
|
||||
* Operator suppression is preserved: when `healthScoreExplainer` is off the
|
||||
* caption does not render (the parent delta digit stays visible because the
|
||||
* parent paints it directly).
|
||||
*/
|
||||
|
||||
interface HealthScoreDeltaExplainerProps {
|
||||
/**
|
||||
* The delta value the parent already painted on the line above. The
|
||||
* explainer doesn't repaint the digit; it sits next to the line
|
||||
* with the `?` glyph and lets the user request the prose.
|
||||
*/
|
||||
/** The delta value the parent paints on the line above. Retained for callers. */
|
||||
delta: number;
|
||||
/**
|
||||
* Optional className for the trigger button so the parent can
|
||||
* align the glyph to the delta line's baseline.
|
||||
*/
|
||||
/** Optional className for the caption so the parent can tune its spacing. */
|
||||
className?: string;
|
||||
/**
|
||||
* Optional id the parent owns and threads to the explainer body via
|
||||
* the popover/sheet markup. The same id sits on the parent's delta
|
||||
* `<span>` as `aria-describedby` so screen readers can connect "−3
|
||||
* vs last week" to the three-sentence read on demand.
|
||||
* Optional id the parent owns and threads to the caption. The same id sits
|
||||
* on the parent's delta `<span>` as `aria-describedby` so screen readers
|
||||
* connect the digit to the read.
|
||||
*/
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
export function HealthScoreDeltaExplainer({
|
||||
delta,
|
||||
className,
|
||||
bodyId,
|
||||
}: HealthScoreDeltaExplainerProps) {
|
||||
const { t } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const flags = useFeatureFlags();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Stable fallback id when the parent doesn't supply one. The body
|
||||
// still paints the id so future consumers can thread the same
|
||||
// describedby pattern without modifying the explainer.
|
||||
// Stable fallback id when the parent doesn't supply one. The hook sits
|
||||
// above the conditional return so the hook order stays stable.
|
||||
const generatedId = useId();
|
||||
const resolvedBodyId = bodyId ?? generatedId;
|
||||
// v1.4.31 — operator can hide the `?` trigger; the delta digit on
|
||||
// the parent line stays visible because the parent reads it
|
||||
// directly. Silent suppression per the architecture brief. The
|
||||
// early return sits below every hook call so the hook order stays
|
||||
// stable across renders.
|
||||
|
||||
// v1.4.31 — operator can hide the read; silent suppression per the
|
||||
// architecture brief.
|
||||
if (!flags.healthScoreExplainer) return null;
|
||||
|
||||
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
|
||||
const title = t("insights.healthScore.deltaExplainer.title");
|
||||
const body = t("insights.healthScore.deltaExplainer.body");
|
||||
|
||||
// Direct click handler on the button on both branches. Earlier
|
||||
// mobile path wrapped the button in a `<span onClick onKeyDown>`
|
||||
// which created two interactive elements in the a11y tree and
|
||||
// intercepted clicks on the 2 px gap around the button. The button
|
||||
// already handles Enter/Space natively; the only thing the wrapper
|
||||
// owned was the open toggle, which moves onto the button itself.
|
||||
const handleOpen = () => setOpen(true);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="health-score-delta-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={resolvedBodyId}
|
||||
className={cn(
|
||||
// Visible glyph stays 12 px so the chip reads at the delta
|
||||
// line's height without inflating the row. The button itself
|
||||
// sits at the 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`;
|
||||
// negative `-my-3 -mx-2` collapses the surrounding row back
|
||||
// to its 16 px stride so the optical chip stays small while
|
||||
// the hit surface lifts to 44 px. Transparent padding owns
|
||||
// the extra reach.
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title}
|
||||
description={t("insights.healthScore.deltaExplainer.description", {
|
||||
delta: delta > 0 ? `+${delta}` : `${delta}`,
|
||||
})}
|
||||
>
|
||||
<p
|
||||
id={bodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="health-score-delta-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
<p
|
||||
data-slot="health-score-delta-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
id={resolvedBodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span
|
||||
id={resolvedBodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className={cn("text-muted-foreground text-xs leading-snug", className)}
|
||||
>
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
import Link from "next/link";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
@@ -188,6 +190,8 @@ export function HeroStrip({
|
||||
returnToBand = null,
|
||||
}: HeroStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
|
||||
// v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the
|
||||
// card's already-localised `{ positive, negative }` shape. Only forwarded when
|
||||
@@ -225,8 +229,12 @@ export function HeroStrip({
|
||||
const greetingBase = t(greetingKey);
|
||||
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
|
||||
const subtitle = briefing?.paragraph ?? t("insights.heroFallbackSubtitle");
|
||||
// v1.22 — calendar-bucketed freshness label ("Updated today, HH:MM" /
|
||||
// "yesterday" / "on DD.MM."), matching the briefing + per-metric cards so the
|
||||
// hero no longer reads relative while the cards below it read absolute. The
|
||||
// day boundary follows the user's profile timezone.
|
||||
const generatedLine = updatedAt
|
||||
? t("insights.heroGenerated", { time: formatRelativeTime(updatedAt, t) })
|
||||
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, user?.timezone)
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -291,12 +299,15 @@ export function HeroStrip({
|
||||
{greeting}
|
||||
</h1>
|
||||
</div>
|
||||
<p
|
||||
{/* v1.22 (W6) — the briefing paragraph renders through ProseBlocks
|
||||
so a multi-paragraph verdict-first briefing shows real paragraph
|
||||
spacing (still plain text children, no markdown). */}
|
||||
<div
|
||||
data-slot="insights-hero-strip-subtitle"
|
||||
className="text-muted-foreground max-w-3xl text-sm leading-relaxed"
|
||||
className="text-muted-foreground max-w-3xl text-sm"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
<ProseBlocks text={subtitle} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span data-slot="insights-hero-strip-baseline">
|
||||
{t("insights.heroPersonalBaseline")}
|
||||
|
||||
@@ -6,10 +6,12 @@ import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { AskCoachAction } from "@/components/insights/ask-coach-action";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
@@ -273,17 +275,18 @@ function StatusBody({ text }: { text: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const paragraphRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Measure the clamped paragraph against its scroll height. When the
|
||||
// text fits inside the three-line clamp `scrollHeight` equals
|
||||
// `clientHeight`; an overflow means the clamp is hiding content and the
|
||||
// toggle earns its place. We measure against the *clamped* element, so
|
||||
// the check is gated on `!expanded` — once expanded the paragraph is no
|
||||
// longer clamped and the comparison would always read "fits".
|
||||
// v1.22 (W6) — the assessment now renders through the shared `ProseBlocks`
|
||||
// helper (real paragraphs). The 3-line clamp therefore moves from the single
|
||||
// `<p>` to the BLOCK CONTAINER: a max-height + overflow-hidden when collapsed,
|
||||
// measured on the wrapper node so multi-paragraph text doesn't re-collapse.
|
||||
// When the text fits, `scrollHeight` equals `clientHeight`; an overflow means
|
||||
// the clamp is hiding content and the toggle earns its place. Gated on
|
||||
// `!expanded` so the comparison reads against the clamped element.
|
||||
useEffect(() => {
|
||||
if (expanded) return;
|
||||
const node = paragraphRef.current;
|
||||
const node = wrapperRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const measure = () => {
|
||||
@@ -301,15 +304,16 @@ function StatusBody({ text }: { text: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
ref={paragraphRef}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-relaxed",
|
||||
!expanded && "line-clamp-3",
|
||||
"text-muted-foreground text-sm",
|
||||
// ~3 lines at text-sm / leading-relaxed; only when collapsed.
|
||||
!expanded && "max-h-[4.5rem] overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
<ProseBlocks text={text} />
|
||||
</div>
|
||||
{/* Only render the toggle on a genuine overflow. When the full text
|
||||
fits inside three lines there is nothing more to show, so no
|
||||
affordance is painted. */}
|
||||
@@ -335,17 +339,25 @@ function StatusBody({ text }: { text: string }) {
|
||||
|
||||
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
if (!updatedAt) return null;
|
||||
return (
|
||||
// v1.11.5 — right-aligned so the timestamp tucks to the trailing edge
|
||||
// of the card for a tidier read against the left-aligned prose above.
|
||||
//
|
||||
// v1.12.2 — the freshness caption is a relative read ("2 hours ago") via
|
||||
// the shared `formatRelativeTime`, matching the briefing / hero /
|
||||
// last-measurement / coach-history captions so two adjacent cards on the
|
||||
// same page no longer read one relative and one absolute.
|
||||
// v1.22 (W6) — the freshness caption is the calendar-bucketed
|
||||
// `formatUpdatedLabel` ("Updated today, 14:30" / "yesterday" / "on DD.MM."),
|
||||
// matching the briefing + per-metric cards. The day boundary follows the
|
||||
// user's profile timezone, not the browser's.
|
||||
<p className="text-muted-foreground text-right text-xs">
|
||||
{t("insights.lastUpdated")}: {formatRelativeTime(updatedAt, t)}
|
||||
{formatUpdatedLabel(
|
||||
updatedAt,
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
user?.timezone,
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
@@ -49,6 +49,12 @@ const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen";
|
||||
export interface CoachNudgeStatus {
|
||||
nudgedAt: string | null;
|
||||
unread: boolean;
|
||||
/**
|
||||
* v1.22 (W5) — the conversation holding the newest assistant message, so
|
||||
* the FAB can deep-link into the proactive thread on the unread path
|
||||
* instead of opening a blank new chat. Null when no Coach reply exists.
|
||||
*/
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +76,7 @@ export function isNudgeUnread(
|
||||
export function LayoutCoachFab() {
|
||||
const { t } = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
@@ -115,8 +122,27 @@ export function LayoutCoachFab() {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive:
|
||||
// the dot also lights when the daily sweep has flipped a "remind me" reminder
|
||||
// to due, independent of the nudge seen-stamp (resolving the reminder in the
|
||||
// ledger clears it). Same cheap, gated, stale-while-revalidate read shape.
|
||||
const { data: dueReminders } = useQuery({
|
||||
queryKey: queryKeys.coachReminders("due,surfaced"),
|
||||
queryFn: async () => {
|
||||
const data = await apiGet<{ reminders?: unknown[] } | undefined>(
|
||||
"/api/coach/reminders?status=due,surfaced",
|
||||
);
|
||||
return data?.reminders ?? [];
|
||||
},
|
||||
enabled: coachAvailable && !onCoachPage,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasDueReminders = (dueReminders?.length ?? 0) > 0;
|
||||
|
||||
const nudgedAt = status?.nudgedAt ?? null;
|
||||
const unread = isNudgeUnread(status, seenStamp);
|
||||
// The visual dot lights for either an unread nudge OR a due reminder.
|
||||
const showDot = unread || hasDueReminders;
|
||||
|
||||
// The unread dot is visual-only (`aria-hidden`) and the swapped
|
||||
// `aria-label` is not announced on mutation — a screen-reader user
|
||||
@@ -182,6 +208,17 @@ export function LayoutCoachFab() {
|
||||
// device, not just this one. The local stamp above already hid the
|
||||
// dot for an instant paint; the mutation makes it durable.
|
||||
if (unread) markSeen.mutate();
|
||||
// v1.22 (W5) — when the Coach has written an UNREAD proactive message,
|
||||
// deep-link straight into that conversation (`/coach?c=<id>`) so the
|
||||
// user lands on what the Coach said, rather than opening a blank new
|
||||
// chat in the drawer. The full page honours `?c=` →
|
||||
// `initialConversationId`. Fall back to the in-place drawer for the
|
||||
// ordinary launcher case (no unread, or no conversation id yet).
|
||||
const conversationId = status?.conversationId ?? null;
|
||||
if (unread && conversationId) {
|
||||
router.push(`/coach?c=${conversationId}`);
|
||||
return;
|
||||
}
|
||||
// v1.16.11 — open the side drawer in place (the launch context the
|
||||
// layout mount consumes) instead of navigating to the chat page;
|
||||
// the conversation arrives without losing the page underneath.
|
||||
@@ -194,7 +231,7 @@ export function LayoutCoachFab() {
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-fab"
|
||||
data-unread={unread ? "true" : undefined}
|
||||
data-unread={showDot ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
@@ -239,7 +276,7 @@ export function LayoutCoachFab() {
|
||||
)}
|
||||
>
|
||||
<Sparkles className="text-background size-6" aria-hidden="true" />
|
||||
{unread ? (
|
||||
{showDot ? (
|
||||
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
|
||||
// Deliberately NOT an alarming red (the medication-card rule:
|
||||
// status via calm signal, never an alarm tint): a small cyan
|
||||
|
||||
@@ -69,10 +69,11 @@ describe("<MoodDiscoveredRelations>", () => {
|
||||
// factor label resolves to the localized measurement name
|
||||
expect(html).toContain("Time in Daylight");
|
||||
expect(html).toContain("higher next-day mood");
|
||||
// The n/r/q detail rides a focusable explainer icon (its tooltip content
|
||||
// is portalled on open, so it is absent from the static markup). Assert
|
||||
// the icon is wired per pair instead of the inline stat string.
|
||||
expect(html).toContain('aria-label="How this was computed"');
|
||||
// v1.22 — the n/r/q detail no longer hides behind an "i" glyph + tooltip;
|
||||
// it reads inline as a muted caption, so the stat string is present in the
|
||||
// static markup.
|
||||
expect(html).toContain('data-slot="mood-explainer-detail"');
|
||||
expect(html).toContain("40 paired days");
|
||||
});
|
||||
|
||||
it("phrases a mood → next-day outcome pair", () => {
|
||||
@@ -85,7 +86,7 @@ describe("<MoodDiscoveredRelations>", () => {
|
||||
expect(html).toContain("lower next-day");
|
||||
});
|
||||
|
||||
it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => {
|
||||
it("filters to mood pairs and reads the full-family caveat inline", () => {
|
||||
const html = renderWith(
|
||||
[
|
||||
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
|
||||
@@ -96,14 +97,12 @@ describe("<MoodDiscoveredRelations>", () => {
|
||||
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
|
||||
1,
|
||||
);
|
||||
// The false-discovery footer + the observational disclaimer fold into the
|
||||
// subsection explainer icon; its tooltip content is portalled on open, so
|
||||
// assert the trigger exists rather than the (now hidden) footer string.
|
||||
// v1.22 — the false-discovery footer + observational disclaimer no longer
|
||||
// hide behind an "i" glyph + tooltip; they read inline as a muted caption,
|
||||
// so both the caption slot and its text are present in the static markup.
|
||||
expect(
|
||||
(html.match(/aria-label="How this was computed"/g) ?? []).length,
|
||||
(html.match(/data-slot="mood-explainer-detail"/g) ?? []).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
// The former muted explainer paragraph + inline footnote rows are gone.
|
||||
expect(html).not.toContain("day-to-day pairs tested");
|
||||
expect(html).not.toContain("hold up after correcting");
|
||||
expect(html).toContain("day-to-day pairs tested");
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.12.4 (C3/C4) — compact explainer affordance for the mood surface.
|
||||
* v1.12.4 (C3/C4) — compact explainer for the mood surface.
|
||||
*
|
||||
* Replaces the low-density inline "how this was computed" text rows
|
||||
* (paired-day counts, the false-discovery footer, the observational
|
||||
* disclaimer) with a single ring/info icon. The detail moves into a
|
||||
* hover/focus tooltip so the surface stops spending a full-width row on a
|
||||
* footnote. The trigger is a real focusable `button` carrying an
|
||||
* `aria-label`, so the explanation is reachable by keyboard and screen
|
||||
* readers — not hover-only. Self-contained `TooltipProvider` so callers
|
||||
* don't need an ambient one.
|
||||
* v1.22 — the round info glyph that hid the statistical caveat behind a
|
||||
* hover/focus tooltip is gone. The maintainer wants the trailing "i"
|
||||
* affordance removed across the insights surface; the descriptive text it
|
||||
* used to gate now reads inline as a muted caption, so the explanation is
|
||||
* always visible rather than disclosure-only. The `label` prop is retained
|
||||
* for callers but is no longer rendered as an accessible trigger name —
|
||||
* there is no trigger to name.
|
||||
*/
|
||||
export function MoodExplainerIcon({
|
||||
label,
|
||||
detail,
|
||||
className,
|
||||
}: {
|
||||
/** Accessible name for the trigger (what the icon explains). */
|
||||
/** Accessible name for the (now removed) trigger. Retained for callers. */
|
||||
label: string;
|
||||
/** The explanation shown in the tooltip. */
|
||||
/** The explanation, rendered inline as a muted caption. */
|
||||
detail: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-current/40 transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="size-3" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
|
||||
{detail}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span
|
||||
data-slot="mood-explainer-detail"
|
||||
className={cn("text-muted-foreground text-xs leading-snug", className)}
|
||||
>
|
||||
{detail}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarRange, Info } from "lucide-react";
|
||||
import { CalendarRange } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { AskCoachAction } from "@/components/insights/ask-coach-action";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
@@ -64,6 +62,13 @@ async function fetchNarrative(
|
||||
return apiGet<NarrativeResponse>(`/api/insights/narrative?period=${period}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22 — the round ⓘ provenance glyph that hid the "how this was computed"
|
||||
* read behind a popover is removed; the maintainer wants the trailing info
|
||||
* affordance gone across the insights surface. The method line — and, when
|
||||
* present, the metrics it drew on — now reads inline as a muted caption at the
|
||||
* foot of the card, always visible rather than disclosure-only.
|
||||
*/
|
||||
function ProvenanceDisclosure({
|
||||
provenance,
|
||||
}: {
|
||||
@@ -72,27 +77,15 @@ function ProvenanceDisclosure({
|
||||
const { t } = useTranslations();
|
||||
const metrics = provenance.metrics.slice(0, 8).join(", ");
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
aria-label={t("insights.narrativeProvenanceLabel")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex size-5 shrink-0 items-center justify-center rounded-full transition-colors"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-sm" align="end">
|
||||
<p className="text-foreground font-medium">
|
||||
{t("insights.narrativeProvenanceLabel")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("insights.narrativeProvenanceMethod")}
|
||||
</p>
|
||||
{metrics ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("insights.narrativeProvenanceMetrics", { metrics })}
|
||||
</p>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p
|
||||
data-slot="period-narrative-provenance"
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
>
|
||||
{t("insights.narrativeProvenanceMethod")}
|
||||
{metrics
|
||||
? ` ${t("insights.narrativeProvenanceMetrics", { metrics })}`
|
||||
: ""}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,6 +102,8 @@ export function PeriodNarrativeCard({
|
||||
className,
|
||||
}: PeriodNarrativeCardProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const [period, setPeriod] = useState<NarrativePeriod>("week");
|
||||
|
||||
const query = useQuery({
|
||||
@@ -168,11 +163,6 @@ export function PeriodNarrativeCard({
|
||||
<SectionHeading
|
||||
icon={CalendarRange}
|
||||
title={t("insights.narrativeTitle")}
|
||||
action={
|
||||
narrative?.provenance ? (
|
||||
<ProvenanceDisclosure provenance={narrative.provenance} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div
|
||||
data-slot="period-narrative-card"
|
||||
@@ -209,21 +199,28 @@ export function PeriodNarrativeCard({
|
||||
{t("insights.narrativePreparing")}
|
||||
</p>
|
||||
) : (
|
||||
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
|
||||
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
|
||||
{narrative?.text}
|
||||
</p>
|
||||
// v1.22 (W6) — real paragraphs via the shared ProseBlocks helper
|
||||
// (still plain text children — NO markdown renderer, XSS posture).
|
||||
<div className="text-foreground text-sm">
|
||||
<ProseBlocks text={narrative?.text ?? ""} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{narrative?.provenance ? (
|
||||
<ProvenanceDisclosure provenance={narrative.provenance} />
|
||||
) : null}
|
||||
|
||||
{narrative ? (
|
||||
<p className="text-muted-foreground text-right text-[11px]">
|
||||
<p className="text-muted-foreground text-right text-xs">
|
||||
{data?.revalidating
|
||||
? t("insights.narrativeUpdating")
|
||||
: t("insights.narrativeUpdated", {
|
||||
time: new Date(narrative.updatedAt).toLocaleDateString(
|
||||
locale === "de" ? "de-DE" : undefined,
|
||||
),
|
||||
})}
|
||||
: formatUpdatedLabel(
|
||||
narrative.updatedAt,
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
user?.timezone,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -233,11 +230,11 @@ export function PeriodNarrativeCard({
|
||||
{narrative ? (
|
||||
<div className="flex justify-end">
|
||||
<AskCoachAction
|
||||
question={
|
||||
question={t(
|
||||
period === "week"
|
||||
? "Walk me through how my last week went across my health metrics — what stands out?"
|
||||
: "Walk me through how my last month went across my health metrics — what stands out?"
|
||||
}
|
||||
? "insights.coach.seed.periodWeek"
|
||||
: "insights.coach.seed.periodMonth",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { learnUrl } from "@/lib/learn-links";
|
||||
import { InlineLearnLink } from "@/components/ui/learn-more-link";
|
||||
|
||||
/**
|
||||
* v1.22 (W5) — shared paragraph renderer for every trusted-as-text AI
|
||||
* narrative surface (the Coach, the daily briefing, the per-metric /
|
||||
* score insight cards, the period narrative).
|
||||
*
|
||||
* The AI surfaces render their prose as plain React text children — there
|
||||
* is NO markdown library and NO `dangerouslySetInnerHTML` anywhere in the
|
||||
* tree (the project's XSS posture). Before this helper, three ad-hoc
|
||||
* approaches were in the tree (a single `<p>`, a `whitespace-pre-line`
|
||||
* `<p>`, and the Coach's word-span renderer), so a model that emitted real
|
||||
* paragraph breaks read as one run-on block.
|
||||
*
|
||||
* `ProseBlocks` settles that into ONE pure, XSS-safe splitter:
|
||||
* - split a TRUSTED plain string on blank lines into real `<p>` blocks;
|
||||
* - a single `\n` inside a paragraph becomes a `<br/>`;
|
||||
* - recognised `/learn/<slug>` references (catalog-whitelisted, already
|
||||
* post-filtered server-side) render as a real, safe `<a>` via
|
||||
* `InlineLearnLink` — a closed-set linkifier, never model HTML.
|
||||
*
|
||||
* Everything is `String.prototype.split` + React children — no parser, no
|
||||
* markdown dependency, no injected markup. The chart-token strip
|
||||
* (`stripChartTokens`) runs PER LINE (not on the whole string): it
|
||||
* normalises intra-line whitespace, so running it on the full text would
|
||||
* collapse the `\n\n` paragraph breaks before they can be split on. Splitting
|
||||
* paragraphs first, then stripping each line, keeps the structure intact
|
||||
* while still ensuring a stray `metric:<TYPE>` token never surfaces.
|
||||
*/
|
||||
|
||||
/** Default paragraph styling shared by every prose surface + the Coach
|
||||
* streaming tail, so the streamed and settled renders line up exactly. */
|
||||
export const PROSE_PARAGRAPH_CLASS =
|
||||
"leading-relaxed [&:not(:first-child)]:mt-3";
|
||||
|
||||
/**
|
||||
* Split a trusted-as-text string into real paragraphs on blank lines.
|
||||
* Whitespace-only fragments are dropped. Exported for the streaming
|
||||
* paragraph-aware renderer + unit tests.
|
||||
*/
|
||||
export function splitParagraphs(text: string): string[] {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog-whitelisted `/learn/<slug>` matcher. Mirrors the post-filter's
|
||||
* grammar (`learn-link-guard.ts`): the host is optional (relative or the
|
||||
* absolute `healthlog.dev/learn/<slug>` form), the slug is the lower-case
|
||||
* hyphenated path segment after `/learn/`. The capture is validated against
|
||||
* the published catalog (`learnUrl`) before an anchor renders — an unknown
|
||||
* slug stays plain text, so a drifted post-filter can never produce a
|
||||
* clickable invented link.
|
||||
*/
|
||||
const LEARN_URL_RE =
|
||||
/(?:https?:\/\/)?(?:[a-z0-9.-]*healthlog\.dev)?\/learn\/[a-z0-9-]+\/?/gi;
|
||||
|
||||
/**
|
||||
* Turn a trusted single line of text into React children, rendering any
|
||||
* catalog-known `/learn/<slug>` reference as a safe `InlineLearnLink` and
|
||||
* leaving everything else as plain text nodes. An unknown slug (which the
|
||||
* server post-filter should already have stripped) falls through as plain
|
||||
* text — fail-closed, never a fabricated anchor.
|
||||
*/
|
||||
function linkifyLearnLinks(line: string): ReactNode {
|
||||
if (!line.includes("/learn/")) return line;
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let key = 0;
|
||||
// `matchAll` over a global regex gives each match with its index so we
|
||||
// slice the surrounding text verbatim — no mutation of the source.
|
||||
for (const match of line.matchAll(LEARN_URL_RE)) {
|
||||
const matched = match[0];
|
||||
const start = match.index ?? 0;
|
||||
// The slug is the path segment after `/learn/`, minus a trailing slash.
|
||||
const slug = matched
|
||||
.slice(matched.indexOf("/learn/") + "/learn/".length)
|
||||
.replace(/\/$/, "")
|
||||
.toLowerCase();
|
||||
if (learnUrl(slug) == null) continue; // unknown slug → leave as plain text
|
||||
|
||||
if (start > lastIndex) nodes.push(line.slice(lastIndex, start));
|
||||
nodes.push(
|
||||
<InlineLearnLink key={`learn-${key++}`} slug={slug}>
|
||||
{matched}
|
||||
</InlineLearnLink>,
|
||||
);
|
||||
lastIndex = start + matched.length;
|
||||
}
|
||||
if (nodes.length === 0) return line;
|
||||
if (lastIndex < line.length) nodes.push(line.slice(lastIndex));
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one paragraph's inner text: each single `\n` becomes a `<br/>`, each
|
||||
* line is (optionally) chart-token-stripped, then run through the
|
||||
* catalog-whitelisted Learn linkifier. Stripping per LINE preserves the
|
||||
* `\n` breaks that a whole-string strip would collapse. Exported so the
|
||||
* Coach streaming renderer can settle completed paragraphs with the same
|
||||
* markup the non-streaming path produces.
|
||||
*/
|
||||
export function ParagraphText({
|
||||
text,
|
||||
strip = false,
|
||||
linkify = true,
|
||||
}: {
|
||||
text: string;
|
||||
strip?: boolean;
|
||||
linkify?: boolean;
|
||||
}) {
|
||||
const lines = text.split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const cleaned = strip ? stripChartTokens(line) : line;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{linkify ? linkifyLearnLinks(cleaned) : cleaned}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProseBlocksProps {
|
||||
/** Trusted-as-text prose (model output already verified server-side). */
|
||||
text: string;
|
||||
/**
|
||||
* Strip stray `metric:<TYPE>` chart tokens first (defence-in-depth).
|
||||
* Defaults true for AI prose; pass false for already-cleaned input or
|
||||
* for plain user text where a literal `metric:` substring is meaningful.
|
||||
*/
|
||||
strip?: boolean;
|
||||
/** Linkify catalog-known `/learn/<slug>` references. Default true. */
|
||||
linkify?: boolean;
|
||||
/** Per-paragraph class override (defaults to `PROSE_PARAGRAPH_CLASS`). */
|
||||
paragraphClassName?: string;
|
||||
}
|
||||
|
||||
export function ProseBlocks({
|
||||
text,
|
||||
strip = true,
|
||||
linkify = true,
|
||||
paragraphClassName,
|
||||
}: ProseBlocksProps) {
|
||||
// Split on blank lines FIRST (raw text), then strip per line inside
|
||||
// `ParagraphText` — a whole-string strip would collapse the breaks.
|
||||
// Drop a paragraph that is empty once its tokens are stripped (a rare
|
||||
// token-only line), so no hollow `<p>` renders.
|
||||
const paras = splitParagraphs(text).filter(
|
||||
(p) => !strip || stripChartTokens(p).length > 0,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{paras.map((p, i) => (
|
||||
<p key={i} className={cn(paragraphClassName ?? PROSE_PARAGRAPH_CLASS)}>
|
||||
<ParagraphText text={p} strip={strip} linkify={linkify} />
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -355,9 +355,9 @@ export function RecommendationCard({
|
||||
// quotes the rec so the conversation lands on this exact suggestion;
|
||||
// when the rec carries a metric source, scope the snapshot to it.
|
||||
const coachScopeSource = scopeSourceFromMetricKey(norm.metricSource?.type);
|
||||
const coachQuestion = `Tell me more about this recommendation: "${stripChartTokens(
|
||||
norm.text,
|
||||
)}" — why does it apply to me, and what should I do about it?`;
|
||||
const coachQuestion = t("insights.coach.seed.recommendation", {
|
||||
text: stripChartTokens(norm.text),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -377,7 +377,7 @@ export function RecommendationCard({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{norm.severity && (
|
||||
<Badge
|
||||
className={`text-[10px] tracking-wide uppercase ${
|
||||
className={`text-xs tracking-wide uppercase ${
|
||||
SEVERITY_BADGE_STYLES[norm.severity] ?? ""
|
||||
}`}
|
||||
variant="outline"
|
||||
|
||||
@@ -34,6 +34,14 @@ interface SectionHeadingProps {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
/** Heading text rendered inside the `<h2>`. */
|
||||
title: ReactNode;
|
||||
/**
|
||||
* Optional one-line description rendered directly under the `<h2>`, in the
|
||||
* same muted typography the detail pages use beneath their headings
|
||||
* (`text-muted-foreground text-sm`). Opt-in per section so the overview can
|
||||
* carry the "descriptive line under every heading" rule without forcing a
|
||||
* subtitle where none reads well.
|
||||
*/
|
||||
subtitle?: ReactNode;
|
||||
/** Optional trailing affordance pinned to the right edge of the row. */
|
||||
action?: ReactNode;
|
||||
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
|
||||
@@ -44,6 +52,7 @@ interface SectionHeadingProps {
|
||||
export function SectionHeading({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
id,
|
||||
className,
|
||||
@@ -58,9 +67,19 @@ export function SectionHeading({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="min-w-0">
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? (
|
||||
<p
|
||||
data-slot="section-heading-subtitle"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{action}</div>
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Info, ListOrdered } from "lucide-react";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
|
||||
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
|
||||
import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope";
|
||||
@@ -208,9 +202,11 @@ export function SubPageShell({
|
||||
also: resolved.also,
|
||||
window: resolved.window,
|
||||
},
|
||||
resolved.question,
|
||||
// v1.22 (W6) — `question` is now an i18n key; resolve it so the composer
|
||||
// seed reads in the user's language.
|
||||
t(resolved.question),
|
||||
);
|
||||
}, [coachLaunch, registerScope, explainerMetric]);
|
||||
}, [coachLaunch, registerScope, explainerMetric, t]);
|
||||
|
||||
return (
|
||||
// The target-adjust provider bridges the header gear (rendered just
|
||||
@@ -344,33 +340,6 @@ export function SubPageShell({
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
|
||||
{/* v1.18.6 — a discreet general-guidance tooltip travels with
|
||||
every metric definition. The reference bands in the body
|
||||
above are population anchors, not medical advice; this
|
||||
caption pins that framing without crowding the prose. */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="metric-reference-guidance"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground ml-1.5 inline-flex",
|
||||
"focus-visible:ring-ring/50 rounded-full align-middle",
|
||||
"focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
aria-label={t(
|
||||
"insights.subPage.explainer.referenceGuidance",
|
||||
)}
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-pretty">
|
||||
{t("insights.subPage.explainer.referenceGuidance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</p>
|
||||
) : null}
|
||||
{description && !explainerMetric ? (
|
||||
|
||||
@@ -200,11 +200,7 @@ export function TrendsRow({
|
||||
<SectionHeading
|
||||
icon={TrendingUp}
|
||||
title={t("insights.trendsRow.title")}
|
||||
action={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.trendsRow.subtitle")}
|
||||
</p>
|
||||
}
|
||||
subtitle={t("insights.trendsRow.subtitle")}
|
||||
/>
|
||||
{/* v1.4.22 A4 — equal-height cards. The annotation prose
|
||||
below each chart varies in length, which used to leave the
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Pencil, Plus } from "lucide-react";
|
||||
import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -21,12 +20,18 @@ import { BiomarkerForm } from "./biomarker-form";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the Biomarker catalog manager.
|
||||
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
|
||||
*
|
||||
* Lives on the Labs page (not Settings) because the catalog IS the Labs
|
||||
* feature's primary object. Lists every defined marker with its unit +
|
||||
* reference range, opens a define-new / edit sheet, and wires delete (the
|
||||
* `onDelete: SetNull` FK keeps existing readings, just unlinks them).
|
||||
* feature's primary object. Each marker renders as a single compact row
|
||||
* (name + unit inline, reference range / panel on a muted sub-line) with a
|
||||
* hide toggle, an edit button (reusing the define/edit sheet), and a delete
|
||||
* (the `onDelete: SetNull` FK keeps existing readings, just unlinks them).
|
||||
*
|
||||
* "Hide" is a soft remove: a marker the user no longer needs leaves the
|
||||
* active list and the lab-entry pickers but keeps its readings + canonical
|
||||
* unit/range. Hidden markers collect in a separate section where they can be
|
||||
* restored or deleted outright.
|
||||
*/
|
||||
export function BiomarkerManager() {
|
||||
const { t } = useTranslations();
|
||||
@@ -50,6 +55,20 @@ export function BiomarkerManager() {
|
||||
onError: () => toast.error(t("labs.biomarker.deleteError")),
|
||||
});
|
||||
|
||||
const hiddenMutation = useMutation({
|
||||
mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) =>
|
||||
apiPut(`/api/biomarkers/${id}`, { hidden }),
|
||||
onSuccess: (_data, { hidden }) => {
|
||||
toast.success(
|
||||
hidden
|
||||
? t("labs.biomarker.hiddenToast")
|
||||
: t("labs.biomarker.unhiddenToast"),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.hideError")),
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
@@ -68,6 +87,73 @@ export function BiomarkerManager() {
|
||||
}
|
||||
|
||||
const markers = data?.biomarkers ?? [];
|
||||
const visible = markers.filter((m) => !m.hidden);
|
||||
const hidden = markers.filter((m) => m.hidden);
|
||||
|
||||
function renderRow(marker: BiomarkerDto) {
|
||||
const range = formatReferenceRange(
|
||||
marker.lowerBound,
|
||||
marker.upperBound,
|
||||
formatLabValue,
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={marker.id}
|
||||
data-hidden={marker.hidden ? "true" : undefined}
|
||||
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 data-[hidden=true]:opacity-70"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">{marker.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{range
|
||||
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() =>
|
||||
hiddenMutation.mutate({ id: marker.id, hidden: !marker.hidden })
|
||||
}
|
||||
disabled={hiddenMutation.isPending}
|
||||
aria-label={
|
||||
marker.hidden
|
||||
? t("labs.biomarker.unhide")
|
||||
: t("labs.biomarker.hide")
|
||||
}
|
||||
>
|
||||
{marker.hidden ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => openEdit(marker)}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(marker.id)}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t("labs.biomarker.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.biomarker.delete")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -84,7 +170,7 @@ export function BiomarkerManager() {
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
@@ -101,60 +187,24 @@ export function BiomarkerManager() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{markers.map((marker) => {
|
||||
const range = formatReferenceRange(
|
||||
marker.lowerBound,
|
||||
marker.upperBound,
|
||||
formatLabValue,
|
||||
);
|
||||
return (
|
||||
<li key={marker.id}>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{marker.unit}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{range
|
||||
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-9"
|
||||
onClick={() => openEdit(marker)}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(marker.id)}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t(
|
||||
"labs.biomarker.deleteConfirmDescription",
|
||||
)}
|
||||
confirmLabel={t("labs.biomarker.delete")}
|
||||
className="size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="space-y-4">
|
||||
{visible.length > 0 ? (
|
||||
<ul className="space-y-2">{visible.map(renderRow)}</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{t("labs.biomarker.allHidden")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hidden.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("labs.biomarker.hiddenSection")}
|
||||
</p>
|
||||
<ul className="space-y-2">{hidden.map(renderRow)}</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveSheet
|
||||
|
||||
@@ -128,8 +128,8 @@ export function LabBiomarkerChart({
|
||||
{t("labs.chart.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ height: CHART_HEIGHT_PX }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { LearnMoreLink } from "@/components/ui/learn-more-link";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
@@ -228,8 +227,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
<p className="text-muted-foreground text-sm">{marker.context}</p>
|
||||
) : null}
|
||||
|
||||
<LearnMoreLink concept="LAB_BIOMARKER" />
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -110,8 +110,12 @@ export function LabForm({
|
||||
null,
|
||||
);
|
||||
|
||||
const markers = catalog?.biomarkers ?? [];
|
||||
const selected = markers.find((m) => m.id === biomarkerId);
|
||||
const allMarkers = catalog?.biomarkers ?? [];
|
||||
const selected = allMarkers.find((m) => m.id === biomarkerId);
|
||||
// v1.22 — hidden markers drop from the picker, but keep the currently
|
||||
// selected one visible (e.g. editing a reading whose marker was later
|
||||
// hidden) so the Select still resolves its label.
|
||||
const markers = allMarkers.filter((m) => !m.hidden || m.id === biomarkerId);
|
||||
|
||||
function handleSelect(next: string) {
|
||||
if (next === DEFINE_NEW) {
|
||||
|
||||
@@ -220,11 +220,12 @@ export function OcrReviewDialog({
|
||||
if (file) onFilePicked(file);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={extract.isPending}
|
||||
className="border-muted-foreground/25 hover:bg-muted/50 focus-visible:ring-ring flex min-h-44 w-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-6 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
||||
className="border-muted-foreground/25 hover:bg-muted/50 h-auto min-h-44 w-full flex-col gap-3 rounded-lg border-dashed p-6 text-center whitespace-normal"
|
||||
>
|
||||
{extract.isPending ? (
|
||||
<>
|
||||
@@ -261,7 +262,7 @@ export function OcrReviewDialog({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 py-2">
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ReferenceRangeBadge({
|
||||
// Compact: drop the icon, shrink the text and padding so the badge reads as a
|
||||
// subtle qualifier of the heading rather than a second focal point.
|
||||
const compactClass = compact
|
||||
? "h-auto gap-0.5 px-1.5 py-0 text-[0.625rem] font-normal [&>svg]:size-2.5"
|
||||
? "h-auto gap-0.5 px-1.5 py-0 text-xs font-normal [&>svg]:size-2.5"
|
||||
: "";
|
||||
|
||||
if (status === "in-range") {
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface BiomarkerDto {
|
||||
panel: string | null;
|
||||
hasContext: boolean;
|
||||
context: string | null;
|
||||
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
|
||||
hidden: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -45,17 +45,17 @@ describe("nav-model destination list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives Vorsorge a top-level nav home in the clinical spine (N-3)", () => {
|
||||
it("gives Checkups a top-level nav home in the clinical spine (N-3)", () => {
|
||||
const hrefs = NAV_DESTINATIONS.map((d) => d.href);
|
||||
expect(hrefs).toContain("/vorsorge");
|
||||
const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/vorsorge");
|
||||
expect(hrefs).toContain("/checkups");
|
||||
const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/checkups");
|
||||
expect(vorsorge?.tKey).toBe("nav.vorsorge");
|
||||
// v1.19.1 (S4) — the fixed spine is Medications → Vorsorge → Labs →
|
||||
// Illness → Insights, so Vorsorge sits after Medications and before Labs.
|
||||
// v1.19.1 (S4) — the fixed spine is Medications → Checkups → Labs →
|
||||
// Illness → Insights, so Checkups sits after Medications and before Labs.
|
||||
expect(hrefs.indexOf("/medications")).toBeLessThan(
|
||||
hrefs.indexOf("/vorsorge"),
|
||||
hrefs.indexOf("/checkups"),
|
||||
);
|
||||
expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/labs"));
|
||||
expect(hrefs.indexOf("/checkups")).toBeLessThan(hrefs.indexOf("/labs"));
|
||||
expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/insights"));
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("visibleNavDestinations module gate", () => {
|
||||
expect(emptyMap).toContain("/measurements");
|
||||
expect(emptyMap).toContain("/medications");
|
||||
expect(emptyMap).toContain("/insights");
|
||||
expect(emptyMap).toContain("/vorsorge");
|
||||
expect(emptyMap).toContain("/checkups");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,7 +210,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
// Feature surfaces that left the always-visible strip stay reachable.
|
||||
expect(hub).toContain("/measurements");
|
||||
expect(hub).toContain("/mood");
|
||||
expect(hub).toContain("/vorsorge");
|
||||
expect(hub).toContain("/checkups");
|
||||
// The shared utility tail rides at the end.
|
||||
expect(hub).toContain("/bugreport");
|
||||
expect(hub).toContain("/settings/account");
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("<SidebarNav> module gating (v1.18.0)", () => {
|
||||
expect(html).toContain('href="/measurements"');
|
||||
expect(html).toContain('href="/medications"');
|
||||
expect(html).toContain('href="/insights"');
|
||||
expect(html).toContain('href="/vorsorge"');
|
||||
expect(html).toContain('href="/checkups"');
|
||||
});
|
||||
|
||||
it("renders a module's nav entry when its module is enabled", () => {
|
||||
|
||||
@@ -104,7 +104,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
// render — not buried three taps deep under Settings → Reminders. The
|
||||
// Reminders hub still links to it; this is the direct front door.
|
||||
{
|
||||
href: "/vorsorge",
|
||||
href: "/checkups",
|
||||
tKey: "nav.vorsorge",
|
||||
icon: Stethoscope,
|
||||
tourId: "nav-vorsorge",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* A compact chart-row card listing the next few due reminders with an
|
||||
* inline primary action per row — the same done-vs-capture branch the
|
||||
* dedicated `/vorsorge` page surfaces, scaled down for the dashboard.
|
||||
* dedicated `/checkups` page surfaces, scaled down for the dashboard.
|
||||
* A free-text / self-planned exam marks done silently; a measurement-
|
||||
* linked reminder opens the real measurement-entry form and satisfies
|
||||
* the reminder in the same action.
|
||||
@@ -109,7 +109,7 @@ export function VorsorgeDashboardCard() {
|
||||
{t("measurementReminders.dashboard.empty")}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/vorsorge">
|
||||
<Link href="/checkups">
|
||||
{t("measurementReminders.dashboard.openLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -90,6 +91,10 @@ import {
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
subPageSlugForType,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7c — cumulative HK types whose list view collapses to one
|
||||
@@ -277,6 +282,20 @@ export function MeasurementList({
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
// v1.22 — clicking a row's type badge drills into that metric's Insights
|
||||
// sub-page when one exists, else into the filtered measurements list. The
|
||||
// reverse map prefers the dedicated single-metric slug (PULSE → `pulse`,
|
||||
// WEIGHT → `weight`); types with no sub-page fall back to the `?type=` deep
|
||||
// link the page already supports.
|
||||
const navigateForType = (type: string) => {
|
||||
const slug = subPageSlugForType(type);
|
||||
router.push(
|
||||
slug
|
||||
? `${INSIGHTS_OVERVIEW_PATH}/${slug}`
|
||||
: `/measurements?type=${encodeURIComponent(type)}`,
|
||||
);
|
||||
};
|
||||
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
|
||||
// and the type selector is hidden; the filter state seeds from it.
|
||||
// v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
|
||||
@@ -922,12 +941,26 @@ export function MeasurementList({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
asChild
|
||||
variant="secondary"
|
||||
className={TYPE_COLORS[m.type] ?? ""}
|
||||
className={`focus-visible:ring-ring/50 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`.trim()}
|
||||
>
|
||||
{TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t(
|
||||
"measurements.openMetricInsights",
|
||||
{
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type}
|
||||
</button>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
@@ -1111,24 +1144,29 @@ export function MeasurementList({
|
||||
</div>
|
||||
)}
|
||||
{Icon && (
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateForType(m.type)}
|
||||
aria-label={t("measurements.openMetricInsights", {
|
||||
type: TYPE_LABEL_KEYS[m.type]
|
||||
? t(TYPE_LABEL_KEYS[m.type])
|
||||
: m.type,
|
||||
})}
|
||||
className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/* v1.4.27 MB7 / CF-76 — bump the metadata
|
||||
badges from `text-[10px]` to `text-[11px]`
|
||||
so the legibility floor (12 px is the
|
||||
mobile baseline; 11 px is the lowest
|
||||
tolerated value for non-primary chrome)
|
||||
holds across the row. */}
|
||||
{/* v1.22 — metadata badges sit on the scale
|
||||
token `text-xs` (12 px, the mobile legibility
|
||||
baseline) instead of an arbitrary per-site
|
||||
size, so the row tracks the type scale. */}
|
||||
{(m.type === "BLOOD_PRESSURE_SYS" ||
|
||||
m.type === "BLOOD_PRESSURE_DIA") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mr-1.5 h-5 px-1 text-[11px]"
|
||||
className="mr-1.5 h-5 px-1 text-xs"
|
||||
>
|
||||
{t(TYPE_LABEL_KEYS[m.type])}
|
||||
</Badge>
|
||||
@@ -1158,7 +1196,7 @@ export function MeasurementList({
|
||||
<SleepNightCaption m={m} />
|
||||
) : (
|
||||
isGrouped && (
|
||||
<span className="text-muted-foreground ml-1.5 text-[11px]">
|
||||
<span className="text-muted-foreground ml-1.5 text-xs">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(m.sampleCount as number),
|
||||
})}
|
||||
@@ -1177,7 +1215,7 @@ export function MeasurementList({
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="measurement-source-badge"
|
||||
className={`ml-1.5 h-4 px-1 text-[11px] ${sourceBadgeClass(m.source)}`.trim()}
|
||||
className={`ml-1.5 h-4 px-1 text-xs ${sourceBadgeClass(m.source)}`.trim()}
|
||||
>
|
||||
{formatMeasurementSource(m.source, t)}
|
||||
</Badge>
|
||||
@@ -1194,8 +1232,7 @@ export function MeasurementList({
|
||||
{isGrouped ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
size="icon-lg"
|
||||
data-testid="measurement-day-expand"
|
||||
onClick={() => toggleExpand(m.dayKey as string)}
|
||||
aria-expanded={isExpanded}
|
||||
@@ -1214,15 +1251,13 @@ export function MeasurementList({
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
size="icon-lg"
|
||||
onClick={() => startEdit(m)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
className="size-11"
|
||||
iconClassName="h-4 w-4"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
@@ -1277,8 +1312,7 @@ export function MeasurementList({
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
size="icon-lg"
|
||||
disabled={page <= 1}
|
||||
onClick={() => goToPage((p) => p - 1)}
|
||||
aria-label={t("measurements.previousPage")}
|
||||
@@ -1287,8 +1321,7 @@ export function MeasurementList({
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
size="icon-lg"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => goToPage((p) => p + 1)}
|
||||
aria-label={t("measurements.nextPage")}
|
||||
@@ -1390,8 +1423,7 @@ export function MeasurementList({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
size="icon-lg"
|
||||
disabled={
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { SettingsSectionFrame } from "./section-frame-harness";
|
||||
import { CoachSection } from "../coach-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { SettingsSectionFrame } from "./section-frame-harness";
|
||||
import { ExportSection } from "../export-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { SettingsSectionFrame } from "./section-frame-harness";
|
||||
import { GesundheitsakteSection } from "../gesundheitsakte-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
|
||||
@@ -65,7 +65,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { SettingsSectionFrame } from "./section-frame-harness";
|
||||
import { MedicationsSection } from "../medications-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en"): string {
|
||||
|
||||
@@ -131,7 +131,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { SettingsSectionFrame } from "./section-frame-harness";
|
||||
import { MoodSection } from "../mood-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en"): string {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Test-only harness that reproduces the page heading the live `SettingsShell`
|
||||
* paints for a given section slug (`settings-shell.tsx` headingBlock). The
|
||||
* per-section SSR smoke tests render a section body through this harness so
|
||||
* they can assert the visible heading + its historic `settings-section-<slug>-title`
|
||||
* id alongside the body — mirroring what a user sees on the route.
|
||||
*
|
||||
* Production no longer wraps section bodies in a standalone frame component:
|
||||
* the shell paints the heading from the slug and each section body is just its
|
||||
* cards. This harness lives in `__tests__` only and is never bundled.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SettingsSectionSlug } from "../section-slugs";
|
||||
|
||||
export function SettingsSectionFrame({
|
||||
slug,
|
||||
children,
|
||||
}: {
|
||||
slug: SettingsSectionSlug;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const titleId = `settings-section-${slug}-title`;
|
||||
|
||||
return (
|
||||
<section aria-labelledby={titleId} className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
|
||||
{t(`settings.sections.${slug}.title`)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(`settings.sections.${slug}.subtitle`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({
|
||||
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
|
||||
}));
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SettingsSectionFrame } from "../settings-section-frame";
|
||||
import { SettingsSectionFrame } from "./section-frame-harness";
|
||||
import type { SettingsSectionSlug } from "../section-slugs";
|
||||
import { AboutSection } from "../about-section";
|
||||
import { AccountSection } from "../account-section";
|
||||
|
||||
@@ -193,7 +193,12 @@ export function AboutSection() {
|
||||
which read as a separate field even though the two values belong
|
||||
on the same row. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader icon={Info} title="HealthLog" className="mb-4" />
|
||||
<SettingsCardHeader
|
||||
icon={Info}
|
||||
title="HealthLog"
|
||||
description={t("settings.about.identityDescription")}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{isLoading || !version ? (
|
||||
<div className="flex items-center gap-2 pl-7">
|
||||
@@ -266,6 +271,7 @@ export function AboutSection() {
|
||||
<SettingsCardHeader
|
||||
icon={BookOpen}
|
||||
title={t("settings.about.linksHeading")}
|
||||
description={t("settings.about.linksDescription")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -129,7 +130,7 @@ export function PasskeyListSection({
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
className="text-xs"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
@@ -198,10 +199,7 @@ export function PasskeyListSection({
|
||||
data-testid="passkeys-mobile-list"
|
||||
>
|
||||
{passkeys.map((pk) => (
|
||||
<li
|
||||
key={pk.id}
|
||||
className="bg-card border-border rounded-lg border p-4"
|
||||
>
|
||||
<SettingsCard as="li" key={pk.id}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pk.name}</p>
|
||||
@@ -211,14 +209,14 @@ export function PasskeyListSection({
|
||||
"Single-device / Multi-device" column. Sits on the
|
||||
same chip row as the backup status and date so all
|
||||
metadata reads as a single horizontal stride. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
className="text-xs"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
@@ -267,7 +265,7 @@ export function PasskeyListSection({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
</SettingsCard>
|
||||
))}
|
||||
</ul>
|
||||
{deleteMsg && (
|
||||
|
||||
@@ -31,9 +31,11 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
|
||||
import { AnthropicProviderForm } from "./anthropic-provider-form";
|
||||
import { CodexProviderForm } from "./codex-provider-form";
|
||||
import { DocumentScanCard } from "./document-scan-card";
|
||||
import { FallbackChainCard } from "./fallback-chain-card";
|
||||
import { LocalProviderForm } from "./local-provider-form";
|
||||
import { OpenAIProviderForm } from "./openai-provider-form";
|
||||
import { ResponseTimeoutCard } from "./response-timeout-card";
|
||||
import { RuntimeActionsRow } from "./runtime-actions-row";
|
||||
import {
|
||||
PROVIDER_TYPES,
|
||||
@@ -171,6 +173,13 @@ export function AiInsightsCard({
|
||||
onSelect={pickProvider}
|
||||
/>
|
||||
|
||||
{/* v1.22 (#89) — per-user response timeout (mainly for slow
|
||||
local/self-hosted backends). */}
|
||||
<ResponseTimeoutCard userProvider={userProvider} />
|
||||
|
||||
{/* v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. */}
|
||||
<DocumentScanCard userProvider={userProvider} />
|
||||
|
||||
<RuntimeActionsRow
|
||||
provider={selectedProvider}
|
||||
userProvider={userProvider}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* v1.22 (#90) — Document scanning (Lab reports).
|
||||
*
|
||||
* Opt-in dedicated provider for the Lab-OCR ingestion path. OFF by default:
|
||||
* scanning uses the main provider chain. When enabled, document/lab-report
|
||||
* processing routes to THIS provider/model/key instead — so a user can keep a
|
||||
* cheap text Coach on one provider and send expensive vision OCR to a
|
||||
* vision-capable one. Surfaces a hint when the chosen model can't read images.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
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 { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
supportsVisionForConfig,
|
||||
type VisionProviderType,
|
||||
} from "@/lib/ai/vision-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { OCR_PROVIDER_TYPES, type UserAIProvider } from "./shared";
|
||||
|
||||
/** Map the OCR provider enum to the vision-capability tag. */
|
||||
function toVisionType(provider: string): VisionProviderType {
|
||||
switch (provider) {
|
||||
case "ANTHROPIC":
|
||||
return "anthropic";
|
||||
case "OPENAI":
|
||||
return "openai";
|
||||
case "LOCAL":
|
||||
return "local";
|
||||
default:
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function DocumentScanCard({
|
||||
userProvider,
|
||||
}: {
|
||||
userProvider: UserAIProvider | null | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [provider, setProvider] = useState<string>("OPENAI");
|
||||
const [model, setModel] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
? `${userProvider.ocrEnabled}|${userProvider.ocrProvider ?? ""}|${userProvider.ocrModel ?? ""}|${userProvider.ocrBaseUrl ?? ""}`
|
||||
: null;
|
||||
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
|
||||
if (seededKey != null && seededKey !== previousSeed) {
|
||||
setPreviousSeed(seededKey);
|
||||
setEnabled(userProvider?.ocrEnabled ?? false);
|
||||
setProvider(userProvider?.ocrProvider ?? "OPENAI");
|
||||
setModel(userProvider?.ocrModel ?? "");
|
||||
setBaseUrl(userProvider?.ocrBaseUrl ?? "");
|
||||
}
|
||||
|
||||
// Vision-capability hint: warn when a model is chosen that can't read images
|
||||
// (local is trust-by-default; the warning surfaces only for cloud providers).
|
||||
const visionOk =
|
||||
!enabled ||
|
||||
model.trim() === "" ||
|
||||
supportsVisionForConfig(toVisionType(provider), model.trim());
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
ocrEnabled: enabled,
|
||||
ocrProvider: provider,
|
||||
ocrModel: model.trim() || null,
|
||||
ocrBaseUrl: provider === "LOCAL" ? baseUrl.trim() || null : null,
|
||||
};
|
||||
if (apiKey.trim()) body.ocrKey = apiKey.trim();
|
||||
await apiPatch("/api/user/ai-provider", body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 space-y-4 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("settings.ai.ocrHeading")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.ocrBody")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-label={t("settings.ai.ocrEnableLabel")}
|
||||
data-testid="ai-ocr-enable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="ai-ocr-provider">
|
||||
{t("settings.ai.ocrProviderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="ai-ocr-provider"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="mt-1 sm:max-w-md"
|
||||
>
|
||||
{OCR_PROVIDER_TYPES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`settings.ai.ocrProviderOption.${p}` as const)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ai-ocr-model">{t("settings.ai.modelLabel")}</Label>
|
||||
<Input
|
||||
id="ai-ocr-model"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={t("settings.ai.ocrModelPlaceholder")}
|
||||
className="mt-1"
|
||||
/>
|
||||
{!visionOk && (
|
||||
<p className="text-warning mt-1 text-xs">
|
||||
{t("settings.ai.ocrVisionHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{provider === "LOCAL" && (
|
||||
<div>
|
||||
<Label htmlFor="ai-ocr-base-url">
|
||||
{t("settings.ai.baseUrlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-ocr-base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ai-ocr-key">{t("settings.ai.ocrKeyLabel")}</Label>
|
||||
<PasswordInput
|
||||
id="ai-ocr-key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={
|
||||
userProvider?.hasOcrKey ? t("settings.ai.savedShort") : ""
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.ai.saveCta")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* v1.22 (#89) — Response timeout (seconds).
|
||||
*
|
||||
* A per-user upstream timeout for AI generation, threaded onto the provider
|
||||
* call (`CompletionParams.timeoutMs`). Surfaced here mainly for local /
|
||||
* self-hosted backends: an MLX/exo server can take >60 s on the first request
|
||||
* while it loads the model, which the legacy 60 s default timed out before the
|
||||
* first token landed. Empty = the generous built-in default (~180 s).
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { UserAIProvider } from "./shared";
|
||||
|
||||
export function ResponseTimeoutCard({
|
||||
userProvider,
|
||||
}: {
|
||||
userProvider: UserAIProvider | null | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
// Seed from the persisted value once it arrives (seed-on-data pattern).
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
? `${userProvider.responseTimeoutSeconds ?? ""}`
|
||||
: null;
|
||||
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
|
||||
if (seededKey != null && seededKey !== previousSeed) {
|
||||
setPreviousSeed(seededKey);
|
||||
setValue(
|
||||
userProvider?.responseTimeoutSeconds != null
|
||||
? String(userProvider.responseTimeoutSeconds)
|
||||
: "",
|
||||
);
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = value.trim();
|
||||
const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed);
|
||||
if (
|
||||
responseTimeoutSeconds !== null &&
|
||||
(!Number.isInteger(responseTimeoutSeconds) ||
|
||||
responseTimeoutSeconds < 10 ||
|
||||
responseTimeoutSeconds > 600)
|
||||
) {
|
||||
throw new Error(t("settings.ai.responseTimeoutInvalid"));
|
||||
}
|
||||
await apiPatch("/api/user/ai-provider", { responseTimeoutSeconds });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.ai.responseTimeoutHeading")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.responseTimeoutBody")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ai-response-timeout">
|
||||
{t("settings.ai.responseTimeoutLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-response-timeout"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={10}
|
||||
max={600}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t("settings.ai.responseTimeoutPlaceholder")}
|
||||
className="mt-1 sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.ai.saveCta")}
|
||||
</Button>
|
||||
</div>
|
||||
{msg && (
|
||||
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,8 +27,20 @@ export interface UserAIProvider {
|
||||
hasLocalKey: boolean;
|
||||
hasOpenaiKey: boolean;
|
||||
openaiKeyPreview: string | null;
|
||||
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
|
||||
responseTimeoutSeconds: number | null;
|
||||
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider config.
|
||||
ocrEnabled: boolean;
|
||||
ocrProvider: string | null;
|
||||
ocrModel: string | null;
|
||||
ocrBaseUrl: string | null;
|
||||
hasOcrKey: boolean;
|
||||
ocrKeyPreview: string | null;
|
||||
}
|
||||
|
||||
/** v1.22 (#90) — providers the dedicated document-scan path accepts. */
|
||||
export const OCR_PROVIDER_TYPES = ["OPENAI", "ANTHROPIC", "LOCAL"] as const;
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B2 — provider tags exposed to the UI. Mirrors
|
||||
* `PROVIDER_CHAIN_TYPES` server-side; kept as a const-array here to
|
||||
|
||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user