mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'main' into feat/vaccinations-phase-1
The visit stage's second half landed while this was in flight and both touched the same three places. Resolved as one set: the link facade carries the reverse document-to-visit direction and the new dose pair side by side, the sharing literals hold both sets of admissions with the count re-added on top, and the language bundles take the shorter module description the trunk settled on.
This commit is contained in:
commit
e1a4b7631c
200 files changed
+13599
-2858
No files matched your search
@@ -4,6 +4,24 @@
|
||||
|
||||
### Added
|
||||
|
||||
- The mood module can now say what a day would have looked like on your own
|
||||
past patterns, and how far the day you recorded sits from that. It is a
|
||||
second, separate reading: your own rating stays the leading one, it is
|
||||
stored exactly as you gave it, and nothing overwrites it. The comparison
|
||||
appears only once there are enough days to say anything honest, it always
|
||||
carries the number of days it was built from and a range rather than a
|
||||
single figure, and it names which of the things you recorded weighed most on
|
||||
that day. It describes a connection between things you wrote down. It never
|
||||
claims a cause.
|
||||
|
||||
Below fifteen days there is nothing at all. Between fifteen and thirty the
|
||||
page says how far along it is instead of guessing. Between thirty and sixty
|
||||
the comparison is there and is labelled as provisional. Where the days are
|
||||
there but nothing in them is steady enough to compare against, it says that
|
||||
too, rather than producing a number to fill the space. The comparison is
|
||||
rebuilt overnight from your entries, and if you delete enough of them it
|
||||
goes away again instead of standing there with a fresh date on it.
|
||||
|
||||
- A mood entry can now carry the shape of the day around it. Four sections sit
|
||||
below the sliders — work, contacts, leisure, and one notable event — and
|
||||
every one of them is closed until you open it, so a quick check-in is still
|
||||
@@ -65,6 +83,54 @@
|
||||
optimistic paint had promised this all along, and the skipped-to-taken flip
|
||||
in the same view already behaved this way.
|
||||
|
||||
- The Appearance list in Settings had a strip of dead space above every row
|
||||
that the first row did not have — 24 px on a desktop, 16 px on a phone,
|
||||
unclickable, sitting between one module and the next. The rows read a step
|
||||
apart from the top of their own list. They now sit the same distance from
|
||||
the line above them, and the geometry sweep measures it so it cannot drift
|
||||
back.
|
||||
|
||||
- The Account settings page is the profile again. Cycle tracking is a module
|
||||
and its switch lives in the Modules hub, so the second on-ramp here is gone
|
||||
along with the "manage in Account" link beside that switch, which pointed at
|
||||
an anchor nothing rendered. Changing the password moved to Security, where
|
||||
the second factors and the passkeys already are, and it leads that page.
|
||||
|
||||
- Five module rows ran two to three times longer than their thirteen
|
||||
neighbours: environment, external assistant access, documents, mental
|
||||
wellbeing and nutrient intake. Every one of them ended in "off by default",
|
||||
restating what the switch beside the row already shows. They are one
|
||||
sentence now, like the rest. The mental-wellbeing row also named two
|
||||
screeners where the module ships four, and what happens to your answers is
|
||||
now said on the mental-wellbeing page itself, where you are about to give
|
||||
them.
|
||||
|
||||
- Research Mode is retired. It was an opt-in for the estimated GLP-1 level
|
||||
curve, but that chart stopped consulting the flag several releases ago and
|
||||
has been visible to everyone since — so the switch on the Advanced page
|
||||
changed no screen in either position. The curve is simply part of the
|
||||
medication page, and the switch, its acknowledgment dialog, and the stored
|
||||
acknowledgment are gone. Advanced is now what it reads as: reset the data,
|
||||
delete the account.
|
||||
|
||||
- The ECG page opened with three lines about what HealthLog does not do
|
||||
before showing a single recording, two of them saying the same thing. They
|
||||
are gone; the recording's own page still states that the result shown is
|
||||
the device's. The device's verdict now rides the timestamp as a tag instead
|
||||
of taking a line of its own. The overview teaser shows the five most recent
|
||||
strips rather than every one ever recorded, while the ECG page itself still
|
||||
lists them all, so nothing is hidden where you go to look. A recording has
|
||||
its own address now, with the back link above the heading, so a strip can be
|
||||
linked to and the browser's own Back works.
|
||||
|
||||
- An entry in the record switcher no longer carries the access level on a
|
||||
line of its own. In a menu that narrow it was truncated more often than it
|
||||
was read, and the banner states it in full once you are inside the record.
|
||||
|
||||
- The record owner's name reads in full on the switcher and the banner when
|
||||
they have set one, ahead of the greeting name — so a delegate entering
|
||||
somebody's record sees whose it is by who they are.
|
||||
|
||||
## [1.37.1] — 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
+415
-7
@@ -932,7 +932,7 @@ paths:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 91
|
||||
maxItems: 92
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -1037,7 +1037,7 @@ paths:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 91
|
||||
maxItems: 92
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -8357,6 +8357,29 @@ paths:
|
||||
"403": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/prognosis:
|
||||
get:
|
||||
tags:
|
||||
- Mood
|
||||
summary: The day's own rating and what the account's days imply (v1.38)
|
||||
description: "The newest stored comparison for the account, with the person's own rating for that day beside it. Written
|
||||
nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings
|
||||
separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error
|
||||
— the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's
|
||||
context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a
|
||||
section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood
|
||||
module is off."
|
||||
responses:
|
||||
"200":
|
||||
description: The day's two readings, or an explained absence.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodPrognosisEnvelope"
|
||||
"401": *a1
|
||||
"403": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood-entries/bulk:
|
||||
post:
|
||||
tags:
|
||||
@@ -9176,6 +9199,10 @@ paths:
|
||||
enum:
|
||||
- MANUAL
|
||||
- OCR
|
||||
encounterId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
required:
|
||||
- takenAt
|
||||
responses:
|
||||
@@ -9477,6 +9504,10 @@ paths:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
encounterId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
required:
|
||||
- rows
|
||||
responses:
|
||||
@@ -9713,6 +9744,12 @@ paths:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
- in: query
|
||||
name: episodeId
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
@@ -9825,6 +9862,36 @@ paths:
|
||||
"403": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/encounters/suggest:
|
||||
get:
|
||||
tags:
|
||||
- Records
|
||||
summary: Which visit does a record dated around this day belong to?
|
||||
description: "Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three
|
||||
capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab
|
||||
panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means
|
||||
offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop
|
||||
reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates:
|
||||
neither happened, so neither produced the record being filed. No AI provider is involved and none is required."
|
||||
parameters:
|
||||
- in: query
|
||||
name: anchor
|
||||
schema:
|
||||
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: true
|
||||
responses:
|
||||
"200":
|
||||
description: The verdict.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuggestEncounterEnvelope"
|
||||
"401": *a1
|
||||
"403": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/encounters/{id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -11813,6 +11880,12 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
description: Only documents linked to this illness/condition episode.
|
||||
- name: encounterId
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Only documents linked to this visit.
|
||||
- name: year
|
||||
in: query
|
||||
required: false
|
||||
@@ -11931,6 +12004,12 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
encounterIds:
|
||||
description: Optional visit pre-links (repeat the field per id). Every id must be a live visit of the caller; an unknown
|
||||
one refuses the upload rather than dropping the link the person asked for.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- file
|
||||
responses:
|
||||
@@ -12034,6 +12113,8 @@ paths:
|
||||
- setKind
|
||||
- linkEpisode
|
||||
- unlinkEpisode
|
||||
- linkEncounter
|
||||
- unlinkEncounter
|
||||
- delete
|
||||
- restore
|
||||
kind:
|
||||
@@ -12052,6 +12133,10 @@ paths:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
encounterId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
required:
|
||||
- ids
|
||||
- action
|
||||
@@ -12136,6 +12221,13 @@ paths:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
encounterIds:
|
||||
maxItems: 20
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
responses:
|
||||
"200":
|
||||
description: The updated document.
|
||||
@@ -16065,7 +16157,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 91
|
||||
maxItems: 92
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -17337,7 +17429,7 @@ components:
|
||||
enabledHeroItemKinds:
|
||||
description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array
|
||||
disables all kinds.
|
||||
maxItems: 8
|
||||
maxItems: 9
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -17350,6 +17442,7 @@ components:
|
||||
- ecg_new_recording
|
||||
- tension_window
|
||||
- same_time_baseline
|
||||
- upcoming_visit
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
@@ -33237,6 +33330,184 @@ components:
|
||||
- generatedAt
|
||||
additionalProperties: false
|
||||
description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.
|
||||
MoodPrognosisEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MoodPrognosis"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MoodPrognosis:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
present:
|
||||
type: boolean
|
||||
const: true
|
||||
date:
|
||||
type: string
|
||||
description: The local day this is about, `YYYY-MM-DD`.
|
||||
predicted:
|
||||
type: number
|
||||
description: The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual,
|
||||
never a measurement — and never to be displayed without `n` and the band.
|
||||
ciLow:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
ciHigh:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal
|
||||
to present the value cautiously.
|
||||
n:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: "Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and
|
||||
one built from 300 look identical otherwise."
|
||||
modelVersion:
|
||||
type: string
|
||||
computedAt:
|
||||
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)))$
|
||||
ageDays:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: Whole days between that day and the reader's today.
|
||||
current:
|
||||
type: boolean
|
||||
description: Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is
|
||||
about.
|
||||
provisional:
|
||||
type: boolean
|
||||
description: "True below 60 days of history: label it as provisional."
|
||||
stage:
|
||||
type: string
|
||||
enum:
|
||||
- provisional
|
||||
- regular
|
||||
- seasonal
|
||||
entries:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Days of history behind the ladder.
|
||||
seasonalUnlocked:
|
||||
type: boolean
|
||||
selfAssessment:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: "The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on
|
||||
any surface that shows both, and it is never merged with `predicted`."
|
||||
deviation:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- within
|
||||
- above
|
||||
- below
|
||||
- type: "null"
|
||||
description: Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the
|
||||
account's own past days suggest.
|
||||
deviationAmount:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: How far outside the band, in scale points. `0` when inside.
|
||||
contributions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MoodPrognosisContribution"
|
||||
required:
|
||||
- present
|
||||
- date
|
||||
- predicted
|
||||
- ciLow
|
||||
- ciHigh
|
||||
- n
|
||||
- modelVersion
|
||||
- computedAt
|
||||
- ageDays
|
||||
- current
|
||||
- provisional
|
||||
- stage
|
||||
- entries
|
||||
- seasonalUnlocked
|
||||
- selfAssessment
|
||||
- deviation
|
||||
- deviationAmount
|
||||
- contributions
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
present:
|
||||
type: boolean
|
||||
const: false
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- no-output-yet
|
||||
- learning-phase
|
||||
- no-pattern
|
||||
description: "`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare.
|
||||
`no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a
|
||||
zero."
|
||||
entries:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
nextThreshold:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
description: Days at which the next step unlocks, when a count is missing.
|
||||
required:
|
||||
- present
|
||||
- reason
|
||||
- entries
|
||||
- nextThreshold
|
||||
additionalProperties: false
|
||||
description: "Two separate readings of one day: what the person recorded, and what a comparison with their own past days
|
||||
would have expected. They are never merged — the distance between them is the finding, and a single blended
|
||||
figure destroys it. The comparison is a regularised regression over the account's own history: observational
|
||||
only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have
|
||||
been expected), must carry `n`, and must not say that anything caused anything."
|
||||
MoodPrognosisContribution:
|
||||
type: object
|
||||
properties:
|
||||
feature:
|
||||
type: string
|
||||
description: "Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`.
|
||||
A client that does not recognise one skips it — an older row can name a feature a later model dropped."
|
||||
contribution:
|
||||
type: number
|
||||
description: "Signed: how far this feature moved the expected value away from the account's own average day. Ordered by
|
||||
absolute size, largest first."
|
||||
required:
|
||||
- feature
|
||||
- contribution
|
||||
additionalProperties: false
|
||||
BulkMoodResponseEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -35267,8 +35538,10 @@ components:
|
||||
- labResults
|
||||
- conditions
|
||||
additionalProperties: false
|
||||
description: The three link families of one visit, each ordered by when it was filed. Present on the detail response,
|
||||
absent from the list.
|
||||
description: "The three link families of one visit, each ordered by when it was filed. Present on BOTH the list and the
|
||||
detail: a card shows counts of what the visit produced, and an edit opened from a list row seeds its pickers
|
||||
from that row — a list without them would make the first read zero and the second unfile everything on save. The
|
||||
list resolves them in three grouped queries for the whole page, not three per row."
|
||||
EncounterLinkedTarget:
|
||||
type: object
|
||||
properties:
|
||||
@@ -35345,6 +35618,99 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
SuggestEncounterEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/EncounterSuggestion"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
EncounterSuggestion:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: one
|
||||
encounter:
|
||||
$ref: "#/components/schemas/EncounterSuggestionCandidate"
|
||||
required:
|
||||
- kind
|
||||
- encounter
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: many
|
||||
encounters:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EncounterSuggestionCandidate"
|
||||
required:
|
||||
- kind
|
||||
- encounters
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
const: none
|
||||
required:
|
||||
- kind
|
||||
additionalProperties: false
|
||||
description: "The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a
|
||||
client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to
|
||||
make impossible."
|
||||
type: object
|
||||
EncounterSuggestionCandidate:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
occurredAt:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- PLANNED
|
||||
- DONE
|
||||
- CANCELLED
|
||||
- NO_SHOW
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- ROUTINE
|
||||
- ACUTE
|
||||
- SPECIALIST
|
||||
- PREVENTIVE
|
||||
- EMERGENCY
|
||||
- HOSPITAL
|
||||
- THERAPY
|
||||
- OTHER
|
||||
practitionerName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- occurredAt
|
||||
- status
|
||||
- kind
|
||||
- practitionerName
|
||||
additionalProperties: false
|
||||
description: One visit a record might belong to, resolved far enough to render a row without a second round trip.
|
||||
`practitionerName` is null when the visit names no practice, which is an absence rather than a gap.
|
||||
GetEncounterEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -37192,6 +37558,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentConditionLink"
|
||||
encounterLinks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentEncounterLink"
|
||||
servingClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -37229,6 +37599,7 @@ components:
|
||||
- factCount
|
||||
- pendingCount
|
||||
- conditionLinks
|
||||
- encounterLinks
|
||||
- servingClass
|
||||
- hasContentIndex
|
||||
- contentIndexSource
|
||||
@@ -37258,6 +37629,26 @@ components:
|
||||
- name
|
||||
additionalProperties: false
|
||||
description: A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label.
|
||||
DocumentEncounterLink:
|
||||
type: object
|
||||
properties:
|
||||
encounterId:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
occurredAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- encounterId
|
||||
- kind
|
||||
- occurredAt
|
||||
additionalProperties: false
|
||||
description: "A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the
|
||||
visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words,
|
||||
a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the
|
||||
constant is what lets a German reader and an English one each see it named correctly."
|
||||
InboundDocumentEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -37515,6 +37906,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentConditionLink"
|
||||
encounterLinks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentEncounterLink"
|
||||
servingClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -37572,6 +37967,7 @@ components:
|
||||
- factCount
|
||||
- pendingCount
|
||||
- conditionLinks
|
||||
- encounterLinks
|
||||
- servingClass
|
||||
- hasContentIndex
|
||||
- contentIndexSource
|
||||
@@ -39268,6 +39664,7 @@ components:
|
||||
- ecg_new_recording
|
||||
- tension_window
|
||||
- same_time_baseline
|
||||
- upcoming_visit
|
||||
description: Closed rail-item kind.
|
||||
itemKey:
|
||||
description: Stable dismiss identity, namespaced `<kind>:...`. Present only on the observational kinds (milestone /
|
||||
@@ -39550,7 +39947,7 @@ components:
|
||||
enabledHeroItemKinds:
|
||||
description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array
|
||||
disables all kinds.
|
||||
maxItems: 8
|
||||
maxItems: 9
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -39563,6 +39960,7 @@ components:
|
||||
- ecg_new_recording
|
||||
- tension_window
|
||||
- same_time_baseline
|
||||
- upcoming_visit
|
||||
updatedAt:
|
||||
description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as
|
||||
`baseUpdatedAt` on the next write. Opaque."
|
||||
@@ -39665,6 +40063,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
fullName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
access:
|
||||
type: string
|
||||
enum:
|
||||
@@ -39702,6 +40104,7 @@ components:
|
||||
- accountId
|
||||
- username
|
||||
- displayName
|
||||
- fullName
|
||||
- access
|
||||
- level
|
||||
- recordKind
|
||||
@@ -39722,6 +40125,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
fullName:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
access:
|
||||
type: string
|
||||
enum:
|
||||
@@ -39759,6 +40166,7 @@ components:
|
||||
- accountId
|
||||
- username
|
||||
- displayName
|
||||
- fullName
|
||||
- access
|
||||
- level
|
||||
- recordKind
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
DELEGATE_STORAGE_STATE_PATH,
|
||||
E2E_DELEGATE_MARKER_KG,
|
||||
E2E_OWNER,
|
||||
E2E_OWNER_FULL_NAME,
|
||||
E2E_USER,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
@@ -312,7 +313,26 @@ test.describe("account sharing", () => {
|
||||
|
||||
const entry = page.locator('[data-slot="account-switcher-entry"]');
|
||||
await expect(entry).toHaveCount(1);
|
||||
await expect(entry).toContainText(E2E_OWNER.username);
|
||||
// v1.37.2 — the owner set a full name, so a delegate sees them by it
|
||||
// rather than by the greeting name or the login handle. This is the
|
||||
// record-owner's full name crossing to a read-only delegate, which is the
|
||||
// deliberate disclosure the maintainer decided on. The username stays on
|
||||
// the row as an attribute, which is the positive control for the label
|
||||
// assertion — without it, a renamed slot would make this read green.
|
||||
await expect(entry).toContainText(E2E_OWNER_FULL_NAME);
|
||||
await expect(entry).toHaveAttribute(
|
||||
"data-account-username",
|
||||
E2E_OWNER.username,
|
||||
);
|
||||
// Two lines: the name and the kind of record. The access level used to
|
||||
// take a third and was truncated more often than it was read; it is what
|
||||
// the banner says once you are inside. The row still CARRIES the level as
|
||||
// an attribute, which is the positive control for the absence below —
|
||||
// without it, a renamed slot would make this read green.
|
||||
await expect(entry).toHaveAttribute("data-access-level", /.+/);
|
||||
await expect(entry).not.toContainText("Read only");
|
||||
await expect(entry).not.toContainText("Can add entries");
|
||||
await expect(entry).not.toContainText("change or remove what is in it");
|
||||
// The way home is in the same menu as the way in.
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-own"]'),
|
||||
@@ -342,10 +362,11 @@ test.describe("account sharing", () => {
|
||||
|
||||
// Wait for CONTENT, and for content the assertion is not about: the
|
||||
// banner naming the owner. A skeleton does not satisfy it, and it says
|
||||
// nothing about the marker either way.
|
||||
// nothing about the marker either way. The owner set a full name, so the
|
||||
// banner names them by it (v1.37.2) — the same label the switcher used.
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(E2E_OWNER.username);
|
||||
await expect(banner).toContainText(E2E_OWNER_FULL_NAME);
|
||||
|
||||
// The document showing the banner has never shown the delegate's own
|
||||
// number. The latch was armed by that document's own init script, so this
|
||||
|
||||
@@ -39,7 +39,7 @@ import type { BrowserContext, Page } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
DELEGATE_STORAGE_STATE_PATH,
|
||||
E2E_OWNER,
|
||||
E2E_OWNER_FULL_NAME,
|
||||
E2E_USER,
|
||||
OWNER_STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
@@ -149,7 +149,8 @@ test.describe("delegated writes", () => {
|
||||
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(E2E_OWNER.username);
|
||||
// v1.37.2 — the owner set a full name, so the banner names them by it.
|
||||
await expect(banner).toContainText(E2E_OWNER_FULL_NAME);
|
||||
// The banner drops its read-only clause on its own once the level says so.
|
||||
await expect(banner).not.toContainText("read it, not change it");
|
||||
});
|
||||
|
||||
@@ -21,6 +21,12 @@ import { settleBeforeMeasure } from "./utils/settle";
|
||||
* 3. **One reading edge.** Every card body starts at the card's inner edge.
|
||||
* The `pl-7` gutter put some bodies 28 px in and some at the edge, on the
|
||||
* same page, one card apart.
|
||||
* 4. **One row rhythm in a list card.** A card whose rows are a `divide-y`
|
||||
* ledger has nothing between two rows but the divider line, and its first
|
||||
* row starts at the card's inner top edge. The card contract carries a
|
||||
* `gap-4 md:gap-6` for a stack of blocks; dropping the padding without
|
||||
* dropping the gap leaves that gap standing between the rows as dead,
|
||||
* unclickable space the top of the list does not have.
|
||||
*
|
||||
* Runs in EN and DE, at desktop and phone widths. German runs 15-25% longer,
|
||||
* and an explainer that wraps in German where English fits is the same defect
|
||||
@@ -210,6 +216,63 @@ async function measureCards(page: import("@playwright/test").Page) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Row rhythm inside a `divide-y` list card, per card on the page.
|
||||
*
|
||||
* A list card is recognised by its own shape rather than by a class name:
|
||||
* three or more direct children, no card header among them, and a divider on
|
||||
* every seam between them. That is the ledger pattern wherever it is written,
|
||||
* and the three-row floor keeps a header-plus-one-bordered-block card — which
|
||||
* claim 2 already owns — out of this one.
|
||||
*
|
||||
* `topInset` is the space above the FIRST row, measured from the card's inner
|
||||
* top edge; `rowGaps` is the space between consecutive rows, over and above
|
||||
* the divider line itself. Both are zero in a correct ledger — which is the
|
||||
* whole point: the first row and every later row sit the same distance from
|
||||
* the line above them.
|
||||
*/
|
||||
async function measureListCards(page: import("@playwright/test").Page) {
|
||||
return page.evaluate(() => {
|
||||
const out: { slot: string; topInset: number; rowGaps: number[] }[] = [];
|
||||
for (const card of document.querySelectorAll('[data-slot="card"]')) {
|
||||
if (card.querySelector(":scope > header")) continue;
|
||||
const rows = Array.from(card.children) as HTMLElement[];
|
||||
if (rows.length < 3) continue;
|
||||
// Tailwind writes the divider on the top edge of every row but the
|
||||
// first, or on the bottom edge of every row but the last, depending on
|
||||
// the utility. Accept either — the pattern is the same ledger.
|
||||
const dividedFromTop = rows
|
||||
.slice(1)
|
||||
.every((row) => parseFloat(getComputedStyle(row).borderTopWidth) > 0);
|
||||
const dividedFromBottom = rows
|
||||
.slice(0, -1)
|
||||
.every(
|
||||
(row) => parseFloat(getComputedStyle(row).borderBottomWidth) > 0,
|
||||
);
|
||||
if (!dividedFromTop && !dividedFromBottom) continue;
|
||||
const cardStyle = getComputedStyle(card);
|
||||
const cardInnerTop =
|
||||
card.getBoundingClientRect().top +
|
||||
parseFloat(cardStyle.borderTopWidth) +
|
||||
parseFloat(cardStyle.paddingTop);
|
||||
out.push({
|
||||
slot: card.getAttribute("data-testid") ?? "card",
|
||||
topInset: rows[0].getBoundingClientRect().top - cardInnerTop,
|
||||
// The divider is drawn inside the row's border box, so it is already
|
||||
// inside the measured top edge — a correct ledger reports 0 here.
|
||||
rowGaps: rows
|
||||
.slice(1)
|
||||
.map(
|
||||
(row, index) =>
|
||||
row.getBoundingClientRect().top -
|
||||
rows[index].getBoundingClientRect().bottom,
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
test.describe(`one piece — ${locale}`, () => {
|
||||
test(`page headers land at one height across every route (${locale})`, async ({
|
||||
@@ -402,5 +465,58 @@ for (const locale of ["en", "de"] as const) {
|
||||
expect(offGap, "header→body gap is not the card's own gap").toEqual([]);
|
||||
expect(offEdge, "card body does not start at the card edge").toEqual([]);
|
||||
});
|
||||
|
||||
test(`list cards keep one row rhythm (${locale})`, async ({ page }) => {
|
||||
test.setTimeout(420_000);
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: "healthlog-locale",
|
||||
value: locale,
|
||||
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
|
||||
const offenders: string[] = [];
|
||||
let measured = 0;
|
||||
for (const route of ROUTES) {
|
||||
const response = await page.goto(route, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
if (response && response.status() >= 400) continue;
|
||||
const heading = page
|
||||
.locator(
|
||||
'main h1:visible, main [role="heading"][aria-level="1"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (!(await heading.isVisible().catch(() => false))) {
|
||||
await heading
|
||||
.waitFor({ state: "visible", timeout: 8_000 })
|
||||
.catch(() => {});
|
||||
}
|
||||
await settleBeforeMeasure(page, page.locator("main").first());
|
||||
for (const card of await measureListCards(page)) {
|
||||
measured += 1;
|
||||
const worstGap = Math.max(0, ...card.rowGaps);
|
||||
if (
|
||||
Math.abs(card.topInset) > TOLERANCE ||
|
||||
worstGap > TOLERANCE ||
|
||||
Math.abs(worstGap - Math.min(...card.rowGaps)) > TOLERANCE
|
||||
) {
|
||||
offenders.push(
|
||||
`${route} (${card.slot}): first row inset ${card.topInset.toFixed(1)}px, ` +
|
||||
`between rows ${[...new Set(card.rowGaps.map((g) => g.toFixed(1)))].join("/")}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[${locale}] list cards measured: ${measured}`);
|
||||
// The sweep has to actually find the ledgers it is about.
|
||||
expect(measured).toBeGreaterThan(0);
|
||||
expect(
|
||||
offenders,
|
||||
"a divided list card puts dead space above its rows that the first row does not have",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -93,22 +93,22 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("/settings/account: action buttons do not overflow their cards", async ({
|
||||
test("/settings/security: action buttons do not overflow their cards", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/account", { waitUntil: "networkidle" });
|
||||
// The change-password card sits on Security now, beside the second
|
||||
// factors and the passkeys. The claim is unchanged; the address is not.
|
||||
await page.goto("/settings/security", { waitUntil: "networkidle" });
|
||||
// Same reason as the grid test below: gate on the element, not the
|
||||
// network, because the read underneath it happens exactly once.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /change password/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// The account page's action button(s) must live within (or above the
|
||||
// bottom of) the parent card. Since v1.18.1 the "restart onboarding"
|
||||
// tour button moved to Settings → Advanced, so account carries the
|
||||
// change-password button; the tour button is matched here too in case
|
||||
// it is present. On mobile the button stacks below the title — it's
|
||||
// The page's action button(s) must live within (or above the bottom of)
|
||||
// the parent card. On mobile the button stacks below the title — it is
|
||||
// allowed to extend the card's height, but not push past the right edge.
|
||||
// The German "Passwort ändern" is the long case this was written for.
|
||||
const overflowCheck = await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll("button"));
|
||||
const targets = buttons.filter((b) => {
|
||||
@@ -134,7 +134,7 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
|
||||
|
||||
expect(
|
||||
overflowCheck.length,
|
||||
"expected to find the change-password action button on /settings/account",
|
||||
"expected to find the change-password action button on /settings/security",
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
|
||||
for (const t of overflowCheck) {
|
||||
|
||||
@@ -65,6 +65,12 @@ export const E2E_OWNER = {
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.37.2 — the owner carries a full name, so the switcher journey can assert
|
||||
* that a delegate sees it. Generic on purpose: fixtures hold no real name.
|
||||
*/
|
||||
export const E2E_OWNER_FULL_NAME = "Test Full Name";
|
||||
|
||||
/**
|
||||
* v1.37.0 — the account that creates and administers managed profiles.
|
||||
*
|
||||
@@ -592,20 +598,22 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role,
|
||||
created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
|
||||
onboarding_completed_at, onboarding_tour_completed, full_name)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
full_name = EXCLUDED.full_name`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_OWNER.username,
|
||||
E2E_OWNER.email,
|
||||
await hashPassword(E2E_OWNER.password),
|
||||
now,
|
||||
E2E_OWNER_FULL_NAME,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
export {
|
||||
E2E_USER,
|
||||
E2E_OWNER,
|
||||
E2E_OWNER_FULL_NAME,
|
||||
E2E_GUARDIAN,
|
||||
E2E_SCOPE_DELEGATE,
|
||||
E2E_SCOPE_RECORDS,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Visits — the incidental-linking promise, proven end to end once.
|
||||
*
|
||||
* The flow this milestone is checked against, in one script:
|
||||
*
|
||||
* 1. create a visit with ONLY a date — nothing else touched, Save enabled;
|
||||
* 2. it appears in the list;
|
||||
* 3. open a document dated inside the ±7-day window;
|
||||
* 4. the suggestion offers that visit;
|
||||
* 5. take the offer;
|
||||
* 6. the document's detail names the visit it now belongs to.
|
||||
*
|
||||
* Every assertion addresses a stable `data-slot`, never viewport text: the
|
||||
* copy is i18n-driven and the mobile project renders the same slots at a
|
||||
* different size, so a text assertion would break for the wrong reason.
|
||||
*
|
||||
* The visit is created through the API rather than by driving the sheet twice:
|
||||
* step 1 is about the SAVE being possible with only a date, which the sheet
|
||||
* assertion below covers, and the linking half needs a visit whose instant is
|
||||
* pinned relative to the seeded document's date.
|
||||
*/
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { ensureVaultFixture, MRT_DOC_ID } from "./setup/vault-fixture";
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await ensureVaultFixture();
|
||||
});
|
||||
|
||||
test.describe("visits", () => {
|
||||
test("a visit saves with a date and nothing else", async ({ page }) => {
|
||||
await page.goto("/checkups");
|
||||
await page.getByTestId("checkups-tab-visits").click();
|
||||
|
||||
const add = page.locator('[data-slot="visits-add"]').first();
|
||||
await expect(add).toBeVisible();
|
||||
await add.click();
|
||||
|
||||
// The date is pre-filled with now, and NOTHING else has been touched.
|
||||
const save = page.locator('[data-slot="encounter-save"]').first();
|
||||
await expect(save).toBeVisible();
|
||||
// The whole product constraint: not disabled, with no practitioner, no
|
||||
// reason, no outcome and no link chosen.
|
||||
await expect(save).toBeEnabled();
|
||||
|
||||
await save.click();
|
||||
|
||||
// It appears in the list. A visit dated now is in the past arm.
|
||||
await expect(page.locator('[data-slot="visit-card"]').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("a document offers the visit its date falls near, and files itself against it", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// The seeded MRI report carries a fixed filing date; the visit is placed
|
||||
// two days before it so the ±7-day window resolves to exactly one
|
||||
// candidate — the branch that pre-selects.
|
||||
const document = await request
|
||||
.get(`/api/documents/inbound/${MRT_DOC_ID}`)
|
||||
.then((res) => res.json());
|
||||
const anchorDay: string =
|
||||
document.data.reportDate ?? document.data.documentDate;
|
||||
expect(
|
||||
anchorDay,
|
||||
"the fixture document carries a filing date",
|
||||
).toBeTruthy();
|
||||
const occurredAt = new Date(`${anchorDay}T09:00:00.000Z`);
|
||||
occurredAt.setUTCDate(occurredAt.getUTCDate() - 2);
|
||||
|
||||
const created = await request.post("/api/encounters", {
|
||||
data: {
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
status: "DONE",
|
||||
kind: "SPECIALIST",
|
||||
},
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
|
||||
await page.goto(`/documents?doc=${MRT_DOC_ID}`);
|
||||
|
||||
const suggestion = page
|
||||
.locator('[data-slot="document-encounter-suggestion"]')
|
||||
.first();
|
||||
await expect(suggestion).toBeVisible({ timeout: 15_000 });
|
||||
// One candidate, so the single-candidate branch: a pre-selectable offer
|
||||
// rather than a picker.
|
||||
await expect(suggestion).toHaveAttribute("data-verdict", "one");
|
||||
|
||||
await page
|
||||
.locator('[data-slot="document-encounter-suggestion-option"]')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The filing landed: the read-only "belongs to visit" row appears.
|
||||
await expect(
|
||||
page.locator('[data-slot="document-visit-links"]').first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
+151
-67
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Erkrankung",
|
||||
"familyHistoryColAgeAtOnset": "Alter bei Beginn",
|
||||
"labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:",
|
||||
"labsCatalogRangeLabel": "gespeicherter Bereich"
|
||||
"labsCatalogRangeLabel": "gespeicherter Bereich",
|
||||
"visitsTitle": "Arztbesuche",
|
||||
"visitsColDate": "Datum",
|
||||
"visitsColPractice": "Praxis",
|
||||
"visitsColKind": "Art",
|
||||
"visitsColReason": "Anlass",
|
||||
"visitsColOutcome": "Ergebnis",
|
||||
"visitsColConditions": "Erkrankungen"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -478,7 +485,8 @@
|
||||
"milestone": "Meilensteine",
|
||||
"ecg_new_recording": "Neue EKG-Aufzeichnungen",
|
||||
"tension_window": "Anspannungsphasen",
|
||||
"same_time_baseline": "Aktivität zur gleichen Tageszeit"
|
||||
"same_time_baseline": "Aktivität zur gleichen Tageszeit",
|
||||
"upcoming_visit": "Termine"
|
||||
},
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutResetTitle": "Dashboard-Layout zurücksetzen?",
|
||||
@@ -1604,26 +1612,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Forschungsmodus aktivieren",
|
||||
"intro": "Der Forschungsmodus ergänzt deine GLP-1-Medikamentenseite um eine geschätzte Wirkstoff-Verlaufskurve. Lies die Abgrenzung unten, bevor du den Modus einschaltest — die Kurve ist eine Lernhilfe, niemals eine Messung und nie ein medizinischer Rat.",
|
||||
"whatItIsHeader": "Was das ist",
|
||||
"whatItIs": "Eine Sägezahnkurve, die die Überlagerung deiner protokollierten Dosen über die Zeit simuliert. Grundlage ist das Ein-Kompartment-Modell nach Bateman mit Absorptions- und Halbwertszeit-Parametern aus den EMA-EPARs der zugelassenen GLP-1-Rezeptoragonisten. Die Y-Achse ist einheitenlos und ohne Beschriftung. Die Form, nicht der Zahlenwert, ist die Aussage der Kurve.",
|
||||
"whatItIsntHeader": "Was das nicht ist",
|
||||
"whatItIsnt": "Kein Blutspiegel. Keine Messung. Keine Vorhersage, wie dein Körper tatsächlich reagiert. Keine Grundlage für Dosierungs-, Zeitpunkt- oder Eskalationsentscheidungen. Für alles, was deine Therapie betrifft, sprich mit deiner verschreibenden Ärztin oder deinem Arzt.",
|
||||
"whyEstimateHeader": "Warum es eine Schätzung ist",
|
||||
"whyEstimate": "Populations-Pharmakokinetik beschreibt den typischen Patienten. Die individuelle Streuung ist groß — das Tirzepatid-Modell Schneck/Urva 2024 weist rund 22 % interindividuelle Variabilität bei der Absorption und rund 49 % beim Verteilungsvolumen aus. Eine Lehrbuchkurve kann weder dein Gewicht, deine Nierenfunktion, deine Injektionstechnik noch deine Zeitgenauigkeit abbilden. Lies die Kurve als Rhythmus deiner Dosierung, nicht als deinen Spiegel.",
|
||||
"mdrBoundaryHeader": "Regulatorische Abgrenzung",
|
||||
"mdrBoundary": "HealthLog stellt öffentliche regulatorische Daten und deine eigenen Einträge dar. Die App stellt keine Prognose, Diagnose, Behandlung oder Therapieempfehlung. Die EU-Verordnung 2017/745 (Medical Device Regulation, MDR) und das Leitfadenpapier MDCG 2021-24 ziehen die Grenze bei Software, die Daten für medizinische Zwecke interpretiert — diese Kurve bleibt klar auf der edukativen Seite dieser Linie. Wenn du das Gefühl hast, die Kurve dränge dich zu einer klinischen Entscheidung, schließe sie.",
|
||||
"citationsHeader": "Quellen",
|
||||
"citations": "EMA European Public Assessment Reports für Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta und Bydureon (Abschnitt 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Disclaimer-Version: {version}",
|
||||
"acknowledgeCta": "Verstanden — aktivieren",
|
||||
"cancelCta": "Abbrechen",
|
||||
"successToast": "Forschungsmodus aktiviert. Die Wirkstoff-Verlaufskurve ist jetzt auf deiner GLP-1-Medikamentenseite sichtbar.",
|
||||
"errorToast": "Forschungsmodus konnte nicht aktiviert werden. Bitte erneut versuchen.",
|
||||
"staleVersionToast": "Der Disclaimer wurde aktualisiert, seit diese Seite geladen wurde. Lade die Seite neu, um die aktuelle Fassung zu lesen."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Geschätzter Wirkstoffverlauf",
|
||||
"axisLabel": "Geschätzter Spiegel (relativ)",
|
||||
@@ -3110,8 +3098,7 @@
|
||||
"valuesBack": "Zurück zu Insights",
|
||||
"valuesBackToMetric": "Zurück zu {metric}",
|
||||
"scoresBack": "Zurück zu Insights",
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts.",
|
||||
"ecgDisclaimer": "HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts."
|
||||
},
|
||||
"units": {
|
||||
"steps": "Schritte",
|
||||
@@ -3785,6 +3772,25 @@
|
||||
"statement": "An Tagen mit „{label}“ lag die Stimmung im Durchschnitt {direction}: {withAvg} gegenüber {withoutAvg}.",
|
||||
"counts": "{withDays} Tage mit, {withoutDays} Tage ohne",
|
||||
"caveat": "Das zeigt einen Zusammenhang, keine Ursache."
|
||||
},
|
||||
"prognosis": {
|
||||
"title": "Deine Einschätzung und was deine bisherigen Tage nahelegen",
|
||||
"selfLabel": "Deine Einschätzung",
|
||||
"expectedLabel": "Auf Basis deiner Muster erwartet",
|
||||
"band": "Bereich {low} bis {high}",
|
||||
"statement": "Auf Basis deiner bisherigen Muster wäre für {date} ein Wert um {value} zu erwarten gewesen. Ermittelt aus {n} Tagen, die alles enthalten, was der Vergleich braucht.",
|
||||
"provisional": "Noch vorläufig: das Muster stützt sich auf eine kurze Strecke von Tagen.",
|
||||
"notToday": "Das beschreibt {date}, nicht heute.",
|
||||
"deviationWithin": "Deine eigene Einschätzung liegt in diesem Bereich.",
|
||||
"deviationAbove": "Deine eigene Einschätzung liegt {amount} Punkte über diesem Bereich.",
|
||||
"deviationBelow": "Deine eigene Einschätzung liegt {amount} Punkte unter diesem Bereich.",
|
||||
"weightedTitle": "Was an diesem Tag am stärksten gewichtet wurde",
|
||||
"weightedUp": "{label} — zählte in Richtung eines höheren erwarteten Werts",
|
||||
"weightedDown": "{label} — zählte in Richtung eines niedrigeren erwarteten Werts",
|
||||
"caveat": "Das ist ein Vergleich mit deinen eigenen bisherigen Tagen, keine Aussage darüber, wie ein Tag zustande gekommen ist.",
|
||||
"learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.",
|
||||
"noPattern": "In den bisher erfassten {entries} Tagen ist nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wer den Kontext eines Tages mit erfasst, macht mehr davon vergleichbar.",
|
||||
"featureValue": "{field}: {value}"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -4255,10 +4261,8 @@
|
||||
},
|
||||
"ecg": {
|
||||
"sectionTitle": "EKG-Aufzeichnungen",
|
||||
"sectionIntro": "Einkanal-EKG-Streifen, die dein Gerät aufgezeichnet hat. HealthLog zeigt nur die Aufzeichnung und das von deinem Gerät erstellte Ergebnis – die Kurve wird nicht gelesen oder ausgewertet.",
|
||||
"loadError": "Deine EKG-Aufzeichnungen konnten nicht geladen werden.",
|
||||
"backToList": "Zurück zu den Aufzeichnungen",
|
||||
"disclaimer": "Dies ist das Ergebnis, das dein Aufzeichnungsgerät erstellt hat. HealthLog liest oder interpretiert EKG-Aufzeichnungen nicht und stellt keine Diagnose.",
|
||||
"clinicianNote": "Wenn dich dieses Ergebnis beunruhigt, besprich diese Aufzeichnung mit einer Ärztin oder einem Arzt.",
|
||||
"resultAttribution": "Aufgezeichnetes Ergebnis: {result}, wie vom Aufzeichnungsgerät gemeldet.",
|
||||
"result": {
|
||||
@@ -4591,19 +4595,6 @@
|
||||
"remainingMany": "Noch {count} Messungen bis zum Trend."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Forschungsmodus",
|
||||
"subtitle": "Optionale Kurve des geschätzten GLP-1-Wirkstoffspiegels nach EMA-Daten.",
|
||||
"toggleLabel": "Geschätzte Wirkstoffkurve anzeigen",
|
||||
"disabledStatus": "Deaktiviert. Die Wirkstoffkurve ist ausgeblendet.",
|
||||
"enabledStaleStatus": "Der Disclaimer wurde aktualisiert. Bestätige unten erneut, um die Kurve wieder anzuzeigen.",
|
||||
"acknowledgedOn": "Bestätigt am {date}.",
|
||||
"rePromptTitle": "Disclaimer aktualisiert",
|
||||
"rePromptBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn erneut und bestätige, damit die Kurve sichtbar bleibt.",
|
||||
"rePromptCta": "Disclaimer erneut bestätigen",
|
||||
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen.",
|
||||
"subtitleDetail": "Nur zur Anschauung — niemals eine Messung oder ein medizinischer Rat."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche",
|
||||
"groups": {
|
||||
@@ -4887,8 +4878,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweitert",
|
||||
"description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten.",
|
||||
"subtitle": "Research-Modus, Daten zurücksetzen und Konto löschen."
|
||||
"description": "Der Notausgang: alle Gesundheitsdaten unwiderruflich löschen oder das Konto auflösen.",
|
||||
"subtitle": "Daten zurücksetzen und Konto löschen."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export & Import",
|
||||
@@ -5977,7 +5968,9 @@
|
||||
"rangeCustomEnd": "Bis",
|
||||
"errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.",
|
||||
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet."
|
||||
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.",
|
||||
"visitOffer": "Am {when} steht ein Termin an: {what}.",
|
||||
"visitOfferApply": "Bericht bis dahin"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
|
||||
"globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.",
|
||||
@@ -6037,14 +6030,6 @@
|
||||
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen."
|
||||
},
|
||||
"genderOther": "Divers",
|
||||
"cycleTracking": {
|
||||
"title": "Zyklus-Tracking",
|
||||
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights.",
|
||||
"enable": "Zyklus-Tracking aktivieren",
|
||||
"manage": "Zyklus-Tracking öffnen",
|
||||
"error": "Zyklus-Tracking konnte nicht aktualisiert werden.",
|
||||
"detail": "Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.",
|
||||
@@ -6285,9 +6270,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Datenschutz-Einstellung",
|
||||
"description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.",
|
||||
"openAi": "KI-Datenschutzmodus",
|
||||
"openResearch": "Forschungsmodus"
|
||||
"description": "Steuere, wie viele deiner Daten an KI-Funktionen weitergegeben werden.",
|
||||
"openAi": "KI-Datenschutzmodus"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -8153,7 +8137,8 @@
|
||||
"openLink": "Vorsorge öffnen"
|
||||
},
|
||||
"showMeasurements": "Messergebnisse anzeigen",
|
||||
"loadError": "Erinnerungen konnten nicht geladen werden."
|
||||
"loadError": "Erinnerungen konnten nicht geladen werden.",
|
||||
"fileVisit": "Termin eintragen"
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Ansicht",
|
||||
@@ -8506,27 +8491,27 @@
|
||||
},
|
||||
"mcp": {
|
||||
"label": "Externer Assistentenzugriff (MCP)",
|
||||
"description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus."
|
||||
"description": "Lesezugriff für einen MCP-fähigen Assistenten, über ein begrenztes Token."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Umwelt",
|
||||
"description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus."
|
||||
"description": "Tägliches Wetter und Tageslicht für deinen Ort, neben Stimmung, Schlaf und Vitalwerten."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Dokumente",
|
||||
"description": "Bewahre jedes medizinische Schreiben, jeden Befund und Scan an einem verschlüsselten, durchsuchbaren Ort auf — verknüpft mit deinen Erkrankungen. Standardmäßig aus."
|
||||
"description": "Arztbriefe, Befunde und Scans verschlüsselt und durchsuchbar an einem Ort."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Seelisches Wohlbefinden",
|
||||
"description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus."
|
||||
"description": "Vier freiwillige Selbsttests neben dem Stimmungstagebuch."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Nährstoffzufuhr",
|
||||
"description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus."
|
||||
"description": "Tägliche Mikronährstoff-Summen aus Apple Health."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Impfungen",
|
||||
"description": "Der Impfpass in strukturierter Form: welche Dosis wann gegeben wurde, in welcher Serie sie steht und wann die nächste Auffrischung ansteht."
|
||||
"description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9023,7 +9008,8 @@
|
||||
},
|
||||
"openDetail": "Details anzeigen",
|
||||
"historyLoadError": "Dein Testverlauf konnte nicht geladen werden",
|
||||
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut."
|
||||
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut.",
|
||||
"privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht."
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
@@ -9310,7 +9296,8 @@
|
||||
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
|
||||
"uploadedMeta": "Hinzugefügt am {date} · {size}",
|
||||
"share": "Teilen",
|
||||
"labValuesLink": "{count} Laborwerte ansehen"
|
||||
"labValuesLink": "{count} Laborwerte ansehen",
|
||||
"visitsLabel": "Gehört zu Termin"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Zum Hochladen ablegen",
|
||||
@@ -9327,7 +9314,8 @@
|
||||
"partialFailure": "{failed} von {total} konnten nicht aktualisiert werden",
|
||||
"failed": "Die Aktion konnte nicht ausgeführt werden",
|
||||
"share": "Teilen",
|
||||
"shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut."
|
||||
"shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut.",
|
||||
"linkVisit": "Termin zuordnen"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Dokumente",
|
||||
@@ -9620,6 +9608,11 @@
|
||||
"below": "Bis {time} stehst du bei {today} Schritten. An einem üblichen Tag sind es um diese Zeit etwa {typical}.",
|
||||
"above": "Bis {time} stehst du bei {today} Schritten — mehr als die {typical}, die um diese Zeit für dich üblich sind."
|
||||
}
|
||||
},
|
||||
"upcomingVisit": {
|
||||
"title": "Termin steht an",
|
||||
"bodyToday": "{what} — heute",
|
||||
"bodyTomorrow": "{what} — morgen"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -9715,7 +9708,8 @@
|
||||
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
|
||||
"scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
|
||||
"scopeCount": "{count} Einträge aus {groups} Bereichen",
|
||||
"scopeSensitive": "inkl. {names}"
|
||||
"scopeSensitive": "inkl. {names}",
|
||||
"leafVisits": "Arztbesuche"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
@@ -9726,9 +9720,7 @@
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Akte öffnen",
|
||||
"ownRecord": "Meine eigene Akte",
|
||||
"readOnly": "Nur lesen",
|
||||
"canWrite": "Darf Einträge anlegen"
|
||||
"ownRecord": "Meine eigene Akte"
|
||||
},
|
||||
"switch": {
|
||||
"failed": "Die Akte konnte nicht geöffnet werden. Bitte noch einmal versuchen.",
|
||||
@@ -10082,6 +10074,98 @@
|
||||
"hospital": "Krankenhaus",
|
||||
"therapy": "Therapietermin",
|
||||
"other": "Arzttermin"
|
||||
},
|
||||
"sectionTitle": "Termine",
|
||||
"sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.",
|
||||
"add": "Termin",
|
||||
"loadError": "Termine konnten nicht geladen werden.",
|
||||
"emptyTitle": "Noch kein Termin erfasst",
|
||||
"emptyDescription": "Ein Datum genügt. Alles andere ist freiwillig.",
|
||||
"upcomingHeading": "Anstehend",
|
||||
"pastHeading": "Vergangen",
|
||||
"createTitle": "Termin erfassen",
|
||||
"editTitle": "Termin bearbeiten",
|
||||
"formDescription": "Nur das Datum ist Pflicht.",
|
||||
"created": "Termin gespeichert.",
|
||||
"updated": "Termin aktualisiert.",
|
||||
"saveFailed": "Termin konnte nicht gespeichert werden.",
|
||||
"checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen — dafür reicht deine Freigabe nicht.",
|
||||
"status": {
|
||||
"planned": "Geplant",
|
||||
"done": "Stattgefunden",
|
||||
"cancelled": "Abgesagt",
|
||||
"noShow": "Nicht wahrgenommen"
|
||||
},
|
||||
"card": {
|
||||
"documentCount": "verknüpfte Dokumente",
|
||||
"labCount": "verknüpfte Laborwerte",
|
||||
"conditionCount": "verknüpfte Erkrankungen"
|
||||
},
|
||||
"form": {
|
||||
"date": "Datum und Uhrzeit",
|
||||
"dateDerivesStatusHint": "Ein Datum in der Zukunft ist ein gebuchter Termin.",
|
||||
"status": "Status",
|
||||
"practitioner": "Praxis oder Ärztin",
|
||||
"practitionerHint": "Optional — du kannst den Namen später nachtragen.",
|
||||
"practitionerNone": "Keine Angabe",
|
||||
"practitionerSearch": "Name oder Praxis suchen",
|
||||
"practitionerNoMatch": "Kein Eintrag gefunden.",
|
||||
"practitionerAdd": "Neu anlegen",
|
||||
"practitionerClear": "Auswahl entfernen",
|
||||
"kind": "Art",
|
||||
"reason": "Anlass",
|
||||
"outcome": "Ergebnis",
|
||||
"outcomeReveal": "Ergebnis eintragen",
|
||||
"linkDocuments": "Dokumente",
|
||||
"linkLabResults": "Laborwerte",
|
||||
"linkConditions": "Erkrankungen",
|
||||
"linkNothingToOffer": "Nichts zum Verknüpfen vorhanden."
|
||||
},
|
||||
"deleteTitle": "Termin löschen?",
|
||||
"deleteDescription": "Der Eintrag wird entfernt. Ist der Termin gebucht, entfällt auch die Erinnerung dazu.",
|
||||
"deleteFailed": "Termin konnte nicht gelöscht werden.",
|
||||
"suggestion": {
|
||||
"single": "Gehört zu diesem Termin?",
|
||||
"choose": "Zu welchem Termin gehört das?"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Termine dazu",
|
||||
"emptyLine": "Noch kein Termin mit dieser Erkrankung verknüpft.",
|
||||
"add": "Termin hinzufügen"
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Nächster Termin: {what} am {when}"
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Vorsorge oder Termine anzeigen",
|
||||
"tabVorsorge": "Vorsorge",
|
||||
"tabVisits": "Termine"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Praxen und Ärzte",
|
||||
"description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.",
|
||||
"linkLabel": "Adressbuch",
|
||||
"add": "Eintrag",
|
||||
"searchPlaceholder": "Name oder Praxis suchen",
|
||||
"loadError": "Adressbuch konnte nicht geladen werden.",
|
||||
"emptyTitle": "Noch kein Eintrag",
|
||||
"emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.",
|
||||
"noMatchTitle": "Kein Treffer",
|
||||
"noMatchDescription": "Zu dieser Suche gibt es keinen Eintrag.",
|
||||
"createTitle": "Eintrag anlegen",
|
||||
"editTitle": "Eintrag bearbeiten",
|
||||
"formDescription": "Nur der Name ist Pflicht.",
|
||||
"name": "Name",
|
||||
"specialty": "Fachrichtung",
|
||||
"practice": "Praxis",
|
||||
"location": "Adresse",
|
||||
"phone": "Telefon",
|
||||
"note": "Notiz",
|
||||
"noteHint": "Verschlüsselt gespeichert.",
|
||||
"saveFailed": "Eintrag konnte nicht gespeichert werden.",
|
||||
"deleteTitle": "Eintrag löschen?",
|
||||
"deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.",
|
||||
"deleteFailed": "Eintrag konnte nicht gelöscht werden."
|
||||
}
|
||||
}
|
||||
+151
-67
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Condition",
|
||||
"familyHistoryColAgeAtOnset": "Age at onset",
|
||||
"labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:",
|
||||
"labsCatalogRangeLabel": "saved range"
|
||||
"labsCatalogRangeLabel": "saved range",
|
||||
"visitsTitle": "Visits",
|
||||
"visitsColDate": "Date",
|
||||
"visitsColPractice": "Practice",
|
||||
"visitsColKind": "Kind",
|
||||
"visitsColReason": "Reason",
|
||||
"visitsColOutcome": "Outcome",
|
||||
"visitsColConditions": "Conditions"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -478,7 +485,8 @@
|
||||
"milestone": "Milestones",
|
||||
"ecg_new_recording": "New ECG recordings",
|
||||
"tension_window": "Tension windows",
|
||||
"same_time_baseline": "Same-time activity"
|
||||
"same_time_baseline": "Same-time activity",
|
||||
"upcoming_visit": "Appointments"
|
||||
},
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutResetTitle": "Reset the dashboard layout?",
|
||||
@@ -1604,26 +1612,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Enable Research Mode",
|
||||
"intro": "Research Mode adds an estimated drug-level chart to your GLP-1 medication page. Read the boundary below before turning it on — the chart is a learning aid, never a measurement, never medical advice.",
|
||||
"whatItIsHeader": "What this is",
|
||||
"whatItIs": "A sawtooth curve simulating how your logged doses superimpose over time, using the one-compartment Bateman model with absorption and half-life parameters published in the EMA EPAR for each approved GLP-1 receptor agonist. The vertical axis is unit-less and unlabeled. The shape, not the number, is what the chart shows.",
|
||||
"whatItIsntHeader": "What this isn't",
|
||||
"whatItIsnt": "Not a blood level. Not a measurement. Not a forecast of how your body actually responds. Not a basis for any dose, timing, or escalation decision. Talk to your prescribing clinician for anything that affects how you take your medication.",
|
||||
"whyEstimateHeader": "Why it's an estimate",
|
||||
"whyEstimate": "Population pharmacokinetics describe the typical patient. Individual variation is large — tirzepatide's Schneck/Urva 2024 model reports ~22% inter-individual variability on absorption and ~49% on volume of distribution. A textbook curve cannot capture your weight, kidney function, injection technique, or timing accuracy. Read the curve as the rhythm of dosing, not as your level.",
|
||||
"mdrBoundaryHeader": "Regulatory boundary",
|
||||
"mdrBoundary": "HealthLog displays public regulatory data and your own log entries. It does not predict, diagnose, treat, or recommend any therapy. EU Regulation 2017/745 (the Medical Device Regulation, MDR) and guidance document MDCG 2021-24 draw the line at software that interprets data for medical purposes — this chart stays decisively on the educational side of that line. If you ever feel it's nudging you toward a clinical decision, close it.",
|
||||
"citationsHeader": "Sources",
|
||||
"citations": "EMA European Public Assessment Reports for Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta, and Bydureon (Section 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Disclaimer version: {version}",
|
||||
"acknowledgeCta": "I understand — enable",
|
||||
"cancelCta": "Cancel",
|
||||
"successToast": "Research Mode enabled. The drug-level chart is now visible on your GLP-1 medication page.",
|
||||
"errorToast": "Could not enable Research Mode. Please try again.",
|
||||
"staleVersionToast": "The disclaimer was updated since this page loaded. Reload to read the current version."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Estimated drug level",
|
||||
"axisLabel": "Estimated level (relative)",
|
||||
@@ -3110,8 +3098,7 @@
|
||||
"valuesBack": "Back to insights",
|
||||
"valuesBackToMetric": "Back to {metric}",
|
||||
"scoresBack": "Back to insights",
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result.",
|
||||
"ecgDisclaimer": "HealthLog shows the trace and the recording — it does not interpret the waveform."
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result."
|
||||
},
|
||||
"units": {
|
||||
"steps": "steps",
|
||||
@@ -3785,6 +3772,25 @@
|
||||
"statement": "On days with “{label}” the mood averaged {direction}: {withAvg} against {withoutAvg}.",
|
||||
"counts": "{withDays} days with, {withoutDays} days without",
|
||||
"caveat": "That shows a connection, not a cause."
|
||||
},
|
||||
"prognosis": {
|
||||
"title": "Your rating, and what your own days suggest",
|
||||
"selfLabel": "Your rating",
|
||||
"expectedLabel": "Expected on your own pattern",
|
||||
"band": "Range {low} to {high}",
|
||||
"statement": "On your own patterns so far, a value around {value} would have been expected for {date}. Built from {n} days that carried everything the comparison needs.",
|
||||
"provisional": "Still provisional: the pattern rests on a short stretch of days.",
|
||||
"notToday": "This describes {date}, not today.",
|
||||
"deviationWithin": "Your own rating sits inside that range.",
|
||||
"deviationAbove": "Your own rating sits {amount} points above that range.",
|
||||
"deviationBelow": "Your own rating sits {amount} points below that range.",
|
||||
"weightedTitle": "What weighed most in this day's comparison",
|
||||
"weightedUp": "{label} — counted towards a higher expected value",
|
||||
"weightedDown": "{label} — counted towards a lower expected value",
|
||||
"caveat": "This is a comparison with your own past days, not a statement about how a day came to go the way it did.",
|
||||
"learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.",
|
||||
"noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. Recording a day's context makes more of it comparable.",
|
||||
"featureValue": "{field}: {value}"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -4255,10 +4261,8 @@
|
||||
},
|
||||
"ecg": {
|
||||
"sectionTitle": "ECG recordings",
|
||||
"sectionIntro": "Single-lead ECG strips recorded by your device. HealthLog only shows the recording and the result your device produced — it does not read or interpret the waveform.",
|
||||
"loadError": "Could not load your ECG recordings.",
|
||||
"backToList": "Back to recordings",
|
||||
"disclaimer": "This is the result your recording device produced. HealthLog does not read or interpret ECG recordings and does not provide a diagnosis.",
|
||||
"clinicianNote": "If this result concerns you, discuss this recording with a clinician.",
|
||||
"resultAttribution": "Recorded result: {result}, as reported by the recording device.",
|
||||
"result": {
|
||||
@@ -4591,19 +4595,6 @@
|
||||
"remainingMany": "{count} more readings to go."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Research Mode",
|
||||
"subtitle": "Opt-in curve of the estimated GLP-1 drug level, from EMA-published data.",
|
||||
"toggleLabel": "Show the estimated drug-level chart",
|
||||
"disabledStatus": "Disabled. The drug-level chart is hidden.",
|
||||
"enabledStaleStatus": "The disclaimer was updated. Re-acknowledge below to bring the chart back.",
|
||||
"acknowledgedOn": "Acknowledged on {date}.",
|
||||
"rePromptTitle": "Disclaimer updated",
|
||||
"rePromptBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and re-confirm to keep the chart visible.",
|
||||
"rePromptCta": "Re-acknowledge disclaimer",
|
||||
"disableError": "Could not disable Research Mode. Please try again.",
|
||||
"subtitleDetail": "Reading-only — never a measurement or medical advice."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections",
|
||||
"groups": {
|
||||
@@ -4887,8 +4878,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record.",
|
||||
"subtitle": "Research mode, data reset, and account deletion."
|
||||
"description": "The way out: erase every health record for good, or close the account entirely.",
|
||||
"subtitle": "Data reset and account deletion."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export & Import",
|
||||
@@ -5977,7 +5968,9 @@
|
||||
"rangeCustomEnd": "To",
|
||||
"errorGeneric": "The export could not be generated. Please try again.",
|
||||
"errorRateLimit": "You have reached the export limit for this hour.",
|
||||
"errorModuleDisabled": "The health record export is switched off for this account."
|
||||
"errorModuleDisabled": "The health record export is switched off for this account.",
|
||||
"visitOffer": "An appointment is coming up on {when}: {what}.",
|
||||
"visitOfferApply": "Report up to then"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.",
|
||||
@@ -6037,14 +6030,6 @@
|
||||
"createDetail": "The token is shown once on creation — store it then; it cannot be recovered."
|
||||
},
|
||||
"genderOther": "Other",
|
||||
"cycleTracking": {
|
||||
"title": "Cycle tracking",
|
||||
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights.",
|
||||
"enable": "Enable cycle tracking",
|
||||
"manage": "Open cycle tracking",
|
||||
"error": "Couldn't update cycle tracking.",
|
||||
"detail": "On by default for accounts set to female; available to everyone."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.",
|
||||
@@ -6285,9 +6270,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Privacy posture",
|
||||
"description": "Control how much your data is shared with AI features and opt-in research.",
|
||||
"openAi": "AI privacy mode",
|
||||
"openResearch": "Research mode"
|
||||
"description": "Control how much of your data is shared with AI features.",
|
||||
"openAi": "AI privacy mode"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -8153,7 +8137,8 @@
|
||||
"openLink": "Open checkups"
|
||||
},
|
||||
"showMeasurements": "Show measurements",
|
||||
"loadError": "Could not load your reminders."
|
||||
"loadError": "Could not load your reminders.",
|
||||
"fileVisit": "Record a visit"
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "View",
|
||||
@@ -8506,27 +8491,27 @@
|
||||
},
|
||||
"mcp": {
|
||||
"label": "External assistant access (MCP)",
|
||||
"description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default."
|
||||
"description": "Read access for an MCP-capable assistant, over a scoped token."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Environment",
|
||||
"description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default."
|
||||
"description": "Daily weather and daylight for your location, beside mood, sleep, and vitals."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Documents",
|
||||
"description": "Keep every medical letter, report, and scan in one encrypted, searchable place — linked to your conditions. Off by default."
|
||||
"description": "Medical letters, reports, and scans in one encrypted, searchable place."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Mental wellbeing",
|
||||
"description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach."
|
||||
"description": "Four opt-in screeners beside mood tracking."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Nutrient intake",
|
||||
"description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default."
|
||||
"description": "Daily micronutrient totals synced from Apple Health."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vaccinations",
|
||||
"description": "The vaccination record in structured form: which dose was given when, where it sits in its series, and when the next booster is due."
|
||||
"description": "The vaccination record, structured: dose, series, next booster."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9023,7 +9008,8 @@
|
||||
},
|
||||
"openDetail": "Show details",
|
||||
"historyLoadError": "Your assessment history could not be loaded",
|
||||
"historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again."
|
||||
"historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again.",
|
||||
"privacyNote": "Your answers are stored encrypted and never reach the AI Coach."
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
@@ -9310,7 +9296,8 @@
|
||||
"previewUnavailable": "No inline preview for this file type",
|
||||
"uploadedMeta": "Added {date} · {size}",
|
||||
"share": "Share",
|
||||
"labValuesLink": "View {count} lab values"
|
||||
"labValuesLink": "View {count} lab values",
|
||||
"visitsLabel": "Belongs to visit"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Drop files to upload",
|
||||
@@ -9327,7 +9314,8 @@
|
||||
"partialFailure": "{failed} of {total} could not be updated",
|
||||
"failed": "The action could not be completed",
|
||||
"share": "Share",
|
||||
"shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again."
|
||||
"shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again.",
|
||||
"linkVisit": "File against visit"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documents",
|
||||
@@ -9620,6 +9608,11 @@
|
||||
"below": "By {time} you are at {today} steps. On a usual day you are around {typical} by now.",
|
||||
"above": "By {time} you are at {today} steps, ahead of the {typical} that is usual for you by now."
|
||||
}
|
||||
},
|
||||
"upcomingVisit": {
|
||||
"title": "Appointment coming up",
|
||||
"bodyToday": "{what} — today",
|
||||
"bodyTomorrow": "{what} — tomorrow"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -9715,7 +9708,8 @@
|
||||
"sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.",
|
||||
"scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.",
|
||||
"scopeCount": "{count} entries from {groups} areas",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visits"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
@@ -9726,9 +9720,7 @@
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Open a record",
|
||||
"ownRecord": "My own record",
|
||||
"readOnly": "Read only",
|
||||
"canWrite": "Can add entries"
|
||||
"ownRecord": "My own record"
|
||||
},
|
||||
"switch": {
|
||||
"failed": "Could not open that record. Please try again.",
|
||||
@@ -10082,6 +10074,98 @@
|
||||
"hospital": "Hospital",
|
||||
"therapy": "Therapy session",
|
||||
"other": "Appointment"
|
||||
},
|
||||
"sectionTitle": "Visits",
|
||||
"sectionDescription": "Doctor's visits and booked appointments in one place.",
|
||||
"add": "Visit",
|
||||
"loadError": "Visits could not be loaded.",
|
||||
"emptyTitle": "No visit recorded yet",
|
||||
"emptyDescription": "A date is enough. Everything else is optional.",
|
||||
"upcomingHeading": "Upcoming",
|
||||
"pastHeading": "Past",
|
||||
"createTitle": "Record a visit",
|
||||
"editTitle": "Edit visit",
|
||||
"formDescription": "Only the date is required.",
|
||||
"created": "Visit saved.",
|
||||
"updated": "Visit updated.",
|
||||
"saveFailed": "The visit could not be saved.",
|
||||
"checkupNotClosed": "Visit saved. The checkup stays open — your access does not cover that.",
|
||||
"status": {
|
||||
"planned": "Planned",
|
||||
"done": "Attended",
|
||||
"cancelled": "Cancelled",
|
||||
"noShow": "Missed"
|
||||
},
|
||||
"card": {
|
||||
"documentCount": "linked documents",
|
||||
"labCount": "linked lab results",
|
||||
"conditionCount": "linked conditions"
|
||||
},
|
||||
"form": {
|
||||
"date": "Date and time",
|
||||
"dateDerivesStatusHint": "A date in the future is a booked appointment.",
|
||||
"status": "Status",
|
||||
"practitioner": "Practice or doctor",
|
||||
"practitionerHint": "Optional — you can add the name later.",
|
||||
"practitionerNone": "Not specified",
|
||||
"practitionerSearch": "Search name or practice",
|
||||
"practitionerNoMatch": "No entry found.",
|
||||
"practitionerAdd": "Add new",
|
||||
"practitionerClear": "Clear selection",
|
||||
"kind": "Kind",
|
||||
"reason": "Reason",
|
||||
"outcome": "Outcome",
|
||||
"outcomeReveal": "Add an outcome",
|
||||
"linkDocuments": "Documents",
|
||||
"linkLabResults": "Lab results",
|
||||
"linkConditions": "Conditions",
|
||||
"linkNothingToOffer": "Nothing here to link yet."
|
||||
},
|
||||
"deleteTitle": "Delete this visit?",
|
||||
"deleteDescription": "The entry is removed. If the visit is booked, its reminder stops with it.",
|
||||
"deleteFailed": "The visit could not be deleted.",
|
||||
"suggestion": {
|
||||
"single": "Does this belong to this visit?",
|
||||
"choose": "Which visit does this belong to?"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Visits about this",
|
||||
"emptyLine": "No visit linked to this condition yet.",
|
||||
"add": "Add a visit"
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Next appointment: {what} on {when}"
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Show checkups or visits",
|
||||
"tabVorsorge": "Checkups",
|
||||
"tabVisits": "Visits"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Practices and doctors",
|
||||
"description": "Your own address book — type a practice once, pick it everywhere.",
|
||||
"linkLabel": "Address book",
|
||||
"add": "Entry",
|
||||
"searchPlaceholder": "Search name or practice",
|
||||
"loadError": "The address book could not be loaded.",
|
||||
"emptyTitle": "No entry yet",
|
||||
"emptyDescription": "Add a practice so you can pick it when you record a visit.",
|
||||
"noMatchTitle": "No match",
|
||||
"noMatchDescription": "No entry matches this search.",
|
||||
"createTitle": "Add an entry",
|
||||
"editTitle": "Edit entry",
|
||||
"formDescription": "Only the name is required.",
|
||||
"name": "Name",
|
||||
"specialty": "Specialty",
|
||||
"practice": "Practice",
|
||||
"location": "Address",
|
||||
"phone": "Phone",
|
||||
"note": "Note",
|
||||
"noteHint": "Stored encrypted.",
|
||||
"saveFailed": "The entry could not be saved.",
|
||||
"deleteTitle": "Delete this entry?",
|
||||
"deleteDescription": "Your existing visits stay, and they keep this name.",
|
||||
"deleteFailed": "The entry could not be deleted."
|
||||
}
|
||||
}
|
||||
+151
-67
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Afección",
|
||||
"familyHistoryColAgeAtOnset": "Edad de inicio",
|
||||
"labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:",
|
||||
"labsCatalogRangeLabel": "rango guardado"
|
||||
"labsCatalogRangeLabel": "rango guardado",
|
||||
"visitsTitle": "Visitas médicas",
|
||||
"visitsColDate": "Fecha",
|
||||
"visitsColPractice": "Consulta",
|
||||
"visitsColKind": "Tipo",
|
||||
"visitsColReason": "Motivo",
|
||||
"visitsColOutcome": "Resultado",
|
||||
"visitsColConditions": "Enfermedades"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -478,7 +485,8 @@
|
||||
"milestone": "Hitos",
|
||||
"ecg_new_recording": "Nuevos registros de ECG",
|
||||
"tension_window": "Periodos de tensión",
|
||||
"same_time_baseline": "Actividad a la misma hora"
|
||||
"same_time_baseline": "Actividad a la misma hora",
|
||||
"upcoming_visit": "Citas"
|
||||
},
|
||||
"layoutReset": "Restablecer valores por defecto",
|
||||
"layoutResetTitle": "¿Restablecer el diseño del panel?",
|
||||
@@ -1604,26 +1612,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Activar el modo Investigación",
|
||||
"intro": "El modo Investigación añade un gráfico estimado del nivel de fármaco a tu página de GLP-1. Lee el límite a continuación antes de activarlo: el gráfico es una ayuda de aprendizaje, nunca una medición, nunca un consejo médico.",
|
||||
"whatItIsHeader": "Qué es esto",
|
||||
"whatItIs": "Una curva en diente de sierra que simula la superposición de tus dosis registradas a lo largo del tiempo, usando el modelo de Bateman de un compartimento con los parámetros de absorción y semivida publicados en el EPAR de la EMA de cada agonista GLP-1 aprobado. El eje vertical es adimensional y sin etiquetas. La forma, no el número, es lo que muestra el gráfico.",
|
||||
"whatItIsntHeader": "Qué no es",
|
||||
"whatItIsnt": "No es un nivel en sangre. No es una medición. No es una predicción de cómo responde realmente tu cuerpo. No es base para ninguna decisión de dosis, tiempo o escalado. Para cualquier cosa que afecte cómo tomas tu medicación, habla con tu médico prescriptor.",
|
||||
"whyEstimateHeader": "Por qué es una estimación",
|
||||
"whyEstimate": "La farmacocinética poblacional describe al paciente típico. La variación individual es grande: el modelo Schneck/Urva 2024 de tirzepatida reporta aproximadamente un 22 % de variabilidad interindividual en la absorción y aproximadamente un 49 % en el volumen de distribución. Una curva de manual no puede capturar tu peso, tu función renal, tu técnica de inyección ni la precisión de tus horarios. Lee la curva como el ritmo de tu dosificación, no como tu nivel.",
|
||||
"mdrBoundaryHeader": "Límite regulatorio",
|
||||
"mdrBoundary": "HealthLog muestra datos regulatorios públicos y tus propios registros. La aplicación no predice, no diagnostica, no trata y no recomienda ninguna terapia. El Reglamento UE 2017/745 (Medical Device Regulation, MDR) y el documento de orientación MDCG 2021-24 trazan la línea con el software que interpreta datos con fines médicos: este gráfico se queda decididamente del lado educativo de esa línea. Si en algún momento sientes que te empuja hacia una decisión clínica, ciérralo.",
|
||||
"citationsHeader": "Fuentes",
|
||||
"citations": "European Public Assessment Reports de la EMA para Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta y Bydureon (Sección 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Versión del disclaimer: {version}",
|
||||
"acknowledgeCta": "Entiendo — activar",
|
||||
"cancelCta": "Cancelar",
|
||||
"successToast": "Modo Investigación activado. La curva de nivel de fármaco es ahora visible en tu página de GLP-1.",
|
||||
"errorToast": "No se pudo activar el modo Investigación. Inténtalo de nuevo.",
|
||||
"staleVersionToast": "El disclaimer se actualizó desde que cargó esta página. Recarga para leer la versión actual."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Nivel de fármaco estimado",
|
||||
"axisLabel": "Nivel estimado (relativo)",
|
||||
@@ -3110,8 +3098,7 @@
|
||||
"valuesBack": "Volver a Insights",
|
||||
"valuesBackToMetric": "Volver a {metric}",
|
||||
"scoresBack": "Volver a Insights",
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo.",
|
||||
"ecgDisclaimer": "HealthLog muestra el trazado y el registro, pero no interpreta la onda."
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo."
|
||||
},
|
||||
"units": {
|
||||
"steps": "pasos",
|
||||
@@ -3785,6 +3772,25 @@
|
||||
"statement": "En los días con «{label}» el ánimo promedió {direction}: {withAvg} frente a {withoutAvg}.",
|
||||
"counts": "{withDays} días con, {withoutDays} días sin",
|
||||
"caveat": "Esto muestra una relación, no una causa."
|
||||
},
|
||||
"prognosis": {
|
||||
"title": "Tu valoración y lo que sugieren tus días anteriores",
|
||||
"selfLabel": "Tu valoración",
|
||||
"expectedLabel": "Esperado según tus propios patrones",
|
||||
"band": "Rango de {low} a {high}",
|
||||
"statement": "Según tus patrones hasta ahora, para {date} cabría esperar un valor en torno a {value}. Calculado a partir de {n} días que reúnen todo lo que la comparación necesita.",
|
||||
"provisional": "Todavía provisional: el patrón se apoya en pocos días.",
|
||||
"notToday": "Esto describe {date}, no hoy.",
|
||||
"deviationWithin": "Tu propia valoración está dentro de ese rango.",
|
||||
"deviationAbove": "Tu propia valoración está {amount} puntos por encima de ese rango.",
|
||||
"deviationBelow": "Tu propia valoración está {amount} puntos por debajo de ese rango.",
|
||||
"weightedTitle": "Lo que más pesó en la comparación de este día",
|
||||
"weightedUp": "{label}: contó hacia un valor esperado más alto",
|
||||
"weightedDown": "{label}: contó hacia un valor esperado más bajo",
|
||||
"caveat": "Es una comparación con tus propios días anteriores, no una afirmación sobre cómo llegó a transcurrir un día.",
|
||||
"learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.",
|
||||
"noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Registrar el contexto del día hace comparable una parte mayor.",
|
||||
"featureValue": "{field}: {value}"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -4255,10 +4261,8 @@
|
||||
},
|
||||
"ecg": {
|
||||
"sectionTitle": "Registros de ECG",
|
||||
"sectionIntro": "Tiras de ECG de una sola derivación registradas por tu dispositivo. HealthLog solo muestra el registro y el resultado que produjo tu dispositivo; no lee ni interpreta la onda.",
|
||||
"loadError": "No se pudieron cargar tus registros de ECG.",
|
||||
"backToList": "Volver a los registros",
|
||||
"disclaimer": "Este es el resultado que produjo tu dispositivo de registro. HealthLog no lee ni interpreta los registros de ECG y no proporciona un diagnóstico.",
|
||||
"clinicianNote": "Si este resultado te preocupa, comenta este registro con un profesional médico.",
|
||||
"resultAttribution": "Resultado registrado: {result}, según lo informado por el dispositivo de registro.",
|
||||
"result": {
|
||||
@@ -4591,19 +4595,6 @@
|
||||
"remainingMany": "Faltan {count} mediciones para la tendencia."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Modo Investigación",
|
||||
"subtitle": "Curva opcional del nivel estimado del fármaco GLP-1, según datos de la EMA.",
|
||||
"toggleLabel": "Mostrar la curva estimada del nivel de fármaco",
|
||||
"disabledStatus": "Desactivado. La curva está oculta.",
|
||||
"enabledStaleStatus": "El disclaimer se actualizó. Reconfirma debajo para volver a mostrar la curva.",
|
||||
"acknowledgedOn": "Confirmado el {date}.",
|
||||
"rePromptTitle": "Disclaimer actualizado",
|
||||
"rePromptBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo y reconfirma para mantener la curva visible.",
|
||||
"rePromptCta": "Reconfirmar disclaimer",
|
||||
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo.",
|
||||
"subtitleDetail": "Solo lectura: nunca una medición ni un consejo médico."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Secciones de ajustes",
|
||||
"groups": {
|
||||
@@ -4887,8 +4878,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avanzado",
|
||||
"description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud.",
|
||||
"subtitle": "Modo investigación, restablecer datos y eliminar cuenta."
|
||||
"description": "La salida: borra todos los registros de salud para siempre o cierra la cuenta.",
|
||||
"subtitle": "Restablecer datos y eliminar cuenta."
|
||||
},
|
||||
"export": {
|
||||
"title": "Exportar e importar",
|
||||
@@ -5977,7 +5968,9 @@
|
||||
"rangeCustomEnd": "Hasta",
|
||||
"errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.",
|
||||
"errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.",
|
||||
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta."
|
||||
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.",
|
||||
"visitOffer": "El {when} hay una cita: {what}.",
|
||||
"visitOfferApply": "Informe hasta esa fecha"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
|
||||
"globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
|
||||
@@ -6037,14 +6030,6 @@
|
||||
"createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar."
|
||||
},
|
||||
"genderOther": "Otro",
|
||||
"cycleTracking": {
|
||||
"title": "Seguimiento del ciclo",
|
||||
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases.",
|
||||
"enable": "Activar el seguimiento del ciclo",
|
||||
"manage": "Abrir seguimiento del ciclo",
|
||||
"error": "No se pudo actualizar el seguimiento del ciclo.",
|
||||
"detail": "Activado por defecto en cuentas configuradas como femeninas; disponible para todos."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
|
||||
@@ -6285,9 +6270,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Configuración de privacidad",
|
||||
"description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.",
|
||||
"openAi": "Modo de privacidad de IA",
|
||||
"openResearch": "Modo investigación"
|
||||
"description": "Controla cuántos de tus datos se comparten con las funciones de IA.",
|
||||
"openAi": "Modo de privacidad de IA"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -8153,7 +8137,8 @@
|
||||
"openLink": "Abrir revisiones"
|
||||
},
|
||||
"showMeasurements": "Ver mediciones",
|
||||
"loadError": "No se pudieron cargar tus recordatorios."
|
||||
"loadError": "No se pudieron cargar tus recordatorios.",
|
||||
"fileVisit": "Registrar una cita"
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Vista",
|
||||
@@ -8506,27 +8491,27 @@
|
||||
},
|
||||
"mcp": {
|
||||
"label": "Acceso de asistente externo (MCP)",
|
||||
"description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada."
|
||||
"description": "Acceso de lectura para un asistente compatible con MCP, mediante un token limitado."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Entorno",
|
||||
"description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto."
|
||||
"description": "El tiempo y las horas de luz diarias de tu ubicación, junto al ánimo, el sueño y las constantes."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Documentos",
|
||||
"description": "Guarda cada carta médica, informe y escaneo en un solo lugar cifrado y con búsqueda — vinculado a tus afecciones. Desactivado por defecto."
|
||||
"description": "Cartas médicas, informes y escaneos en un lugar cifrado y con búsqueda."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Bienestar mental",
|
||||
"description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA."
|
||||
"description": "Cuatro cuestionarios opcionales, junto al registro de ánimo."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Ingesta de nutrientes",
|
||||
"description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto."
|
||||
"description": "Totales diarios de micronutrientes sincronizados desde Apple Health."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vacunas",
|
||||
"description": "La cartilla de vacunación en forma estructurada: qué dosis se administró y cuándo, en qué lugar de la serie está y cuándo toca el próximo refuerzo."
|
||||
"description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9023,7 +9008,8 @@
|
||||
},
|
||||
"openDetail": "Ver detalles",
|
||||
"historyLoadError": "No se pudo cargar tu historial de evaluaciones",
|
||||
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo."
|
||||
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.",
|
||||
"privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA."
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
@@ -9310,7 +9296,8 @@
|
||||
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
|
||||
"uploadedMeta": "Añadido el {date} · {size}",
|
||||
"share": "Compartir",
|
||||
"labValuesLink": "Ver {count} valores de laboratorio"
|
||||
"labValuesLink": "Ver {count} valores de laboratorio",
|
||||
"visitsLabel": "Pertenece a la cita"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Suelta los archivos para subirlos",
|
||||
@@ -9327,7 +9314,8 @@
|
||||
"partialFailure": "No se pudieron actualizar {failed} de {total}",
|
||||
"failed": "No se pudo completar la acción",
|
||||
"share": "Compartir",
|
||||
"shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo."
|
||||
"shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo.",
|
||||
"linkVisit": "Asignar a una cita"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documentos",
|
||||
@@ -9620,6 +9608,11 @@
|
||||
"below": "A las {time} llevas {today} pasos. En un día habitual rondas los {typical} a esta hora.",
|
||||
"above": "A las {time} llevas {today} pasos, por encima de los {typical} habituales a esta hora."
|
||||
}
|
||||
},
|
||||
"upcomingVisit": {
|
||||
"title": "Cita próxima",
|
||||
"bodyToday": "{what}: hoy",
|
||||
"bodyTomorrow": "{what}: mañana"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -9715,7 +9708,8 @@
|
||||
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
|
||||
"scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
|
||||
"scopeCount": "{count} entradas de {groups} áreas",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visitas médicas"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
@@ -9726,9 +9720,7 @@
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Abrir un historial",
|
||||
"ownRecord": "Mi propio historial",
|
||||
"readOnly": "Solo lectura",
|
||||
"canWrite": "Puede añadir entradas"
|
||||
"ownRecord": "Mi propio historial"
|
||||
},
|
||||
"switch": {
|
||||
"failed": "No se pudo abrir ese historial. Inténtalo de nuevo.",
|
||||
@@ -10082,6 +10074,98 @@
|
||||
"hospital": "Hospital",
|
||||
"therapy": "Sesión de terapia",
|
||||
"other": "Cita médica"
|
||||
},
|
||||
"sectionTitle": "Citas",
|
||||
"sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.",
|
||||
"add": "Cita",
|
||||
"loadError": "No se pudieron cargar las citas.",
|
||||
"emptyTitle": "Todavía no hay ninguna cita",
|
||||
"emptyDescription": "Basta con una fecha. Todo lo demás es opcional.",
|
||||
"upcomingHeading": "Próximas",
|
||||
"pastHeading": "Pasadas",
|
||||
"createTitle": "Registrar una cita",
|
||||
"editTitle": "Editar la cita",
|
||||
"formDescription": "Solo la fecha es obligatoria.",
|
||||
"created": "Cita guardada.",
|
||||
"updated": "Cita actualizada.",
|
||||
"saveFailed": "No se pudo guardar la cita.",
|
||||
"checkupNotClosed": "Cita guardada. La revisión sigue pendiente: tu acceso no lo permite.",
|
||||
"status": {
|
||||
"planned": "Prevista",
|
||||
"done": "Realizada",
|
||||
"cancelled": "Cancelada",
|
||||
"noShow": "No acudida"
|
||||
},
|
||||
"card": {
|
||||
"documentCount": "documentos vinculados",
|
||||
"labCount": "resultados de laboratorio vinculados",
|
||||
"conditionCount": "enfermedades vinculadas"
|
||||
},
|
||||
"form": {
|
||||
"date": "Fecha y hora",
|
||||
"dateDerivesStatusHint": "Una fecha futura es una cita reservada.",
|
||||
"status": "Estado",
|
||||
"practitioner": "Consulta o médico",
|
||||
"practitionerHint": "Opcional: puedes añadir el nombre más tarde.",
|
||||
"practitionerNone": "Sin indicar",
|
||||
"practitionerSearch": "Buscar nombre o consulta",
|
||||
"practitionerNoMatch": "No se encontró ninguna entrada.",
|
||||
"practitionerAdd": "Añadir nueva",
|
||||
"practitionerClear": "Quitar la selección",
|
||||
"kind": "Tipo",
|
||||
"reason": "Motivo",
|
||||
"outcome": "Resultado",
|
||||
"outcomeReveal": "Añadir un resultado",
|
||||
"linkDocuments": "Documentos",
|
||||
"linkLabResults": "Resultados de laboratorio",
|
||||
"linkConditions": "Enfermedades",
|
||||
"linkNothingToOffer": "Todavía no hay nada que vincular."
|
||||
},
|
||||
"deleteTitle": "¿Eliminar esta cita?",
|
||||
"deleteDescription": "Se elimina la entrada. Si la cita está reservada, su recordatorio también desaparece.",
|
||||
"deleteFailed": "No se pudo eliminar la cita.",
|
||||
"suggestion": {
|
||||
"single": "¿Pertenece a esta cita?",
|
||||
"choose": "¿A qué cita pertenece?"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Citas relacionadas",
|
||||
"emptyLine": "Todavía no hay ninguna cita vinculada a esta enfermedad.",
|
||||
"add": "Añadir una cita"
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Próxima cita: {what} el {when}"
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Mostrar revisiones o citas",
|
||||
"tabVorsorge": "Revisiones",
|
||||
"tabVisits": "Citas"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Consultas y médicos",
|
||||
"description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.",
|
||||
"linkLabel": "Agenda",
|
||||
"add": "Entrada",
|
||||
"searchPlaceholder": "Buscar nombre o consulta",
|
||||
"loadError": "No se pudo cargar la agenda.",
|
||||
"emptyTitle": "Todavía no hay entradas",
|
||||
"emptyDescription": "Añade una consulta para poder elegirla al registrar una cita.",
|
||||
"noMatchTitle": "Sin coincidencias",
|
||||
"noMatchDescription": "Ninguna entrada coincide con esta búsqueda.",
|
||||
"createTitle": "Añadir una entrada",
|
||||
"editTitle": "Editar la entrada",
|
||||
"formDescription": "Solo el nombre es obligatorio.",
|
||||
"name": "Nombre",
|
||||
"specialty": "Especialidad",
|
||||
"practice": "Consulta",
|
||||
"location": "Dirección",
|
||||
"phone": "Teléfono",
|
||||
"note": "Nota",
|
||||
"noteHint": "Se guarda cifrada.",
|
||||
"saveFailed": "No se pudo guardar la entrada.",
|
||||
"deleteTitle": "¿Eliminar esta entrada?",
|
||||
"deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.",
|
||||
"deleteFailed": "No se pudo eliminar la entrada."
|
||||
}
|
||||
}
|
||||
+151
-67
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Affection",
|
||||
"familyHistoryColAgeAtOnset": "Âge au début",
|
||||
"labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :",
|
||||
"labsCatalogRangeLabel": "plage enregistrée"
|
||||
"labsCatalogRangeLabel": "plage enregistrée",
|
||||
"visitsTitle": "Consultations",
|
||||
"visitsColDate": "Date",
|
||||
"visitsColPractice": "Cabinet",
|
||||
"visitsColKind": "Type",
|
||||
"visitsColReason": "Motif",
|
||||
"visitsColOutcome": "Résultat",
|
||||
"visitsColConditions": "Maladies"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -478,7 +485,8 @@
|
||||
"milestone": "Étapes franchies",
|
||||
"ecg_new_recording": "Nouveaux enregistrements ECG",
|
||||
"tension_window": "Périodes de tension",
|
||||
"same_time_baseline": "Activité à la même heure"
|
||||
"same_time_baseline": "Activité à la même heure",
|
||||
"upcoming_visit": "Rendez-vous"
|
||||
},
|
||||
"layoutReset": "Rétablir les valeurs par défaut",
|
||||
"layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?",
|
||||
@@ -1604,26 +1612,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Activer le mode Recherche",
|
||||
"intro": "Le mode Recherche ajoute un graphique estimé du niveau de médicament à votre page GLP-1. Lisez la limite ci-dessous avant de l'activer — le graphique est un outil d'apprentissage, jamais une mesure, jamais un conseil médical.",
|
||||
"whatItIsHeader": "Ce que c'est",
|
||||
"whatItIs": "Une courbe en dents de scie qui simule la superposition de vos doses enregistrées dans le temps, à partir du modèle Bateman à un compartiment avec les paramètres d'absorption et de demi-vie publiés dans l'EPAR EMA de chaque agoniste GLP-1 approuvé. L'axe vertical est sans unité et non étiqueté. C'est la forme, pas la valeur, qui est l'information de la courbe.",
|
||||
"whatItIsntHeader": "Ce que ce n'est pas",
|
||||
"whatItIsnt": "Pas un taux sanguin. Pas une mesure. Pas une prévision de la réponse réelle de votre corps. Pas une base pour une décision de dose, de timing ou d'escalade. Pour tout ce qui touche à votre traitement, parlez-en à votre médecin prescripteur.",
|
||||
"whyEstimateHeader": "Pourquoi c'est une estimation",
|
||||
"whyEstimate": "La pharmacocinétique de population décrit le patient typique. La variabilité individuelle est grande — le modèle Schneck/Urva 2024 du tirzépatide rapporte environ 22 % de variabilité interindividuelle sur l'absorption et environ 49 % sur le volume de distribution. Une courbe de manuel ne peut capturer ni votre poids, ni votre fonction rénale, ni votre technique d'injection, ni la précision de vos horaires. Lisez la courbe comme le rythme de votre dosage, pas comme votre niveau.",
|
||||
"mdrBoundaryHeader": "Limite réglementaire",
|
||||
"mdrBoundary": "HealthLog affiche des données réglementaires publiques et vos propres enregistrements. L'application ne prédit, ne diagnostique, ne traite et ne recommande aucune thérapie. Le règlement UE 2017/745 (Medical Device Regulation, MDR) et le document de guidance MDCG 2021-24 tracent la limite à partir des logiciels qui interprètent des données à des fins médicales — cette courbe reste résolument du côté éducatif de cette limite. Si vous avez le sentiment qu'elle vous pousse vers une décision clinique, fermez-la.",
|
||||
"citationsHeader": "Sources",
|
||||
"citations": "Rapports européens publics d'évaluation (EPAR) de l'EMA pour Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta et Bydureon (section 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Version du disclaimer : {version}",
|
||||
"acknowledgeCta": "J'ai compris — activer",
|
||||
"cancelCta": "Annuler",
|
||||
"successToast": "Mode Recherche activé. La courbe de niveau de médicament est maintenant visible sur votre page GLP-1.",
|
||||
"errorToast": "Impossible d'activer le mode Recherche. Veuillez réessayer.",
|
||||
"staleVersionToast": "Le disclaimer a été mis à jour depuis le chargement de cette page. Rechargez pour lire la version actuelle."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Niveau de médicament estimé",
|
||||
"axisLabel": "Niveau estimé (relatif)",
|
||||
@@ -3110,8 +3098,7 @@
|
||||
"valuesBack": "Retour aux Insights",
|
||||
"valuesBackToMetric": "Retour à {metric}",
|
||||
"scoresBack": "Retour aux Insights",
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil.",
|
||||
"ecgDisclaimer": "HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil."
|
||||
},
|
||||
"units": {
|
||||
"steps": "pas",
|
||||
@@ -3785,6 +3772,25 @@
|
||||
"statement": "Les jours avec « {label} », l'humeur était en moyenne {direction} : {withAvg} contre {withoutAvg}.",
|
||||
"counts": "{withDays} jours avec, {withoutDays} jours sans",
|
||||
"caveat": "Cela montre un lien, pas une cause."
|
||||
},
|
||||
"prognosis": {
|
||||
"title": "Ton évaluation et ce que suggèrent tes journées passées",
|
||||
"selfLabel": "Ton évaluation",
|
||||
"expectedLabel": "Attendu selon tes propres habitudes",
|
||||
"band": "Plage de {low} à {high}",
|
||||
"statement": "D'après tes habitudes jusqu'ici, une valeur autour de {value} aurait été attendue pour {date}. Établi à partir de {n} journées réunissant tout ce dont la comparaison a besoin.",
|
||||
"provisional": "Encore provisoire : le motif repose sur une courte série de journées.",
|
||||
"notToday": "Cela décrit {date}, pas aujourd'hui.",
|
||||
"deviationWithin": "Ta propre évaluation se situe dans cette plage.",
|
||||
"deviationAbove": "Ta propre évaluation se situe {amount} points au-dessus de cette plage.",
|
||||
"deviationBelow": "Ta propre évaluation se situe {amount} points en dessous de cette plage.",
|
||||
"weightedTitle": "Ce qui a le plus pesé dans la comparaison de cette journée",
|
||||
"weightedUp": "{label} — comptait vers une valeur attendue plus élevée",
|
||||
"weightedDown": "{label} — comptait vers une valeur attendue plus basse",
|
||||
"caveat": "C'est une comparaison avec tes propres journées passées, pas une affirmation sur la manière dont une journée s'est déroulée.",
|
||||
"learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.",
|
||||
"noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Noter le contexte d'une journée en rend davantage comparable.",
|
||||
"featureValue": "{field} : {value}"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -4255,10 +4261,8 @@
|
||||
},
|
||||
"ecg": {
|
||||
"sectionTitle": "Enregistrements ECG",
|
||||
"sectionIntro": "Tracés ECG à une seule dérivation enregistrés par votre appareil. HealthLog affiche uniquement l'enregistrement et le résultat produit par votre appareil ; il ne lit ni n'interprète le tracé.",
|
||||
"loadError": "Impossible de charger vos enregistrements ECG.",
|
||||
"backToList": "Retour aux enregistrements",
|
||||
"disclaimer": "Il s'agit du résultat produit par votre appareil d'enregistrement. HealthLog ne lit ni n'interprète les enregistrements ECG et ne fournit aucun diagnostic.",
|
||||
"clinicianNote": "Si ce résultat vous préoccupe, parlez de cet enregistrement à un professionnel de santé.",
|
||||
"resultAttribution": "Résultat enregistré : {result}, tel que signalé par l'appareil d'enregistrement.",
|
||||
"result": {
|
||||
@@ -4591,19 +4595,6 @@
|
||||
"remainingMany": "Encore {count} mesures avant la tendance."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Mode Recherche",
|
||||
"subtitle": "Courbe optionnelle du niveau estimé de GLP-1, d'après les données de l'EMA.",
|
||||
"toggleLabel": "Afficher la courbe estimée du niveau de médicament",
|
||||
"disabledStatus": "Désactivé. La courbe est masquée.",
|
||||
"enabledStaleStatus": "Le disclaimer a été mis à jour. Reconfirmez ci-dessous pour réafficher la courbe.",
|
||||
"acknowledgedOn": "Confirmé le {date}.",
|
||||
"rePromptTitle": "Disclaimer mis à jour",
|
||||
"rePromptBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le et reconfirmez pour garder la courbe visible.",
|
||||
"rePromptCta": "Reconfirmer le disclaimer",
|
||||
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer.",
|
||||
"subtitleDetail": "Lecture seule — jamais une mesure ni un conseil médical."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sections des réglages",
|
||||
"groups": {
|
||||
@@ -4887,8 +4878,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avancé",
|
||||
"description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé.",
|
||||
"subtitle": "Mode recherche, réinitialisation des données et suppression du compte."
|
||||
"description": "La sortie : effacer définitivement toutes les données de santé ou fermer le compte.",
|
||||
"subtitle": "Réinitialisation des données et suppression du compte."
|
||||
},
|
||||
"export": {
|
||||
"title": "Export et import",
|
||||
@@ -5977,7 +5968,9 @@
|
||||
"rangeCustomEnd": "Au",
|
||||
"errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.",
|
||||
"errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.",
|
||||
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte."
|
||||
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.",
|
||||
"visitOffer": "Un rendez-vous est prévu le {when} : {what}.",
|
||||
"visitOfferApply": "Rapport jusque-là"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
|
||||
"globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.",
|
||||
@@ -6037,14 +6030,6 @@
|
||||
"createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré."
|
||||
},
|
||||
"genderOther": "Autre",
|
||||
"cycleTracking": {
|
||||
"title": "Suivi du cycle",
|
||||
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases.",
|
||||
"enable": "Activer le suivi du cycle",
|
||||
"manage": "Ouvrir le suivi du cycle",
|
||||
"error": "Impossible de mettre à jour le suivi du cycle.",
|
||||
"detail": "Activé par défaut pour les comptes définis comme féminins ; disponible pour tous."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.",
|
||||
@@ -6285,9 +6270,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Posture de confidentialité",
|
||||
"description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.",
|
||||
"openAi": "Mode confidentialité IA",
|
||||
"openResearch": "Mode recherche"
|
||||
"description": "Contrôlez la quantité de données partagées avec les fonctions d'IA.",
|
||||
"openAi": "Mode confidentialité IA"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -8153,7 +8137,8 @@
|
||||
"openLink": "Ouvrir les examens"
|
||||
},
|
||||
"showMeasurements": "Afficher les mesures",
|
||||
"loadError": "Impossible de charger vos rappels."
|
||||
"loadError": "Impossible de charger vos rappels.",
|
||||
"fileVisit": "Enregistrer un rendez-vous"
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Affichage",
|
||||
@@ -8506,27 +8491,27 @@
|
||||
},
|
||||
"mcp": {
|
||||
"label": "Accès assistant externe (MCP)",
|
||||
"description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut."
|
||||
"description": "Accès en lecture pour un assistant compatible MCP, via un jeton limité."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Environnement",
|
||||
"description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut."
|
||||
"description": "Météo et durée du jour quotidiennes de votre lieu, à côté de l'humeur, du sommeil et des constantes."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Documents",
|
||||
"description": "Conservez chaque courrier médical, compte rendu et scan dans un espace chiffré et consultable — lié à vos affections. Désactivé par défaut."
|
||||
"description": "Courriers médicaux, comptes rendus et scans dans un espace chiffré et consultable."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Bien-être mental",
|
||||
"description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA."
|
||||
"description": "Quatre questionnaires facultatifs, à côté du suivi de l'humeur."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Apports en nutriments",
|
||||
"description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut."
|
||||
"description": "Totaux quotidiens de micronutriments synchronisés depuis Apple Health."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vaccinations",
|
||||
"description": "Le carnet de vaccination sous forme structurée : quelle dose a été administrée et quand, sa place dans la série, et la date du prochain rappel."
|
||||
"description": "Le carnet de vaccination structuré : dose, série, prochain rappel."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9023,7 +9008,8 @@
|
||||
},
|
||||
"openDetail": "Voir les détails",
|
||||
"historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé",
|
||||
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie."
|
||||
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.",
|
||||
"privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA."
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
@@ -9310,7 +9296,8 @@
|
||||
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
|
||||
"uploadedMeta": "Ajouté le {date} · {size}",
|
||||
"share": "Partager",
|
||||
"labValuesLink": "Voir {count} valeurs de laboratoire"
|
||||
"labValuesLink": "Voir {count} valeurs de laboratoire",
|
||||
"visitsLabel": "Rattaché au rendez-vous"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Déposez les fichiers pour les téléverser",
|
||||
@@ -9327,7 +9314,8 @@
|
||||
"partialFailure": "Impossible de mettre à jour {failed} sur {total}",
|
||||
"failed": "L'action n'a pas pu être effectuée",
|
||||
"share": "Partager",
|
||||
"shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez."
|
||||
"shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez.",
|
||||
"linkVisit": "Classer sous un rendez-vous"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documents",
|
||||
@@ -9620,6 +9608,11 @@
|
||||
"below": "À {time} vous en êtes à {today} pas. Un jour habituel, vous tournez autour de {typical} à cette heure.",
|
||||
"above": "À {time} vous en êtes à {today} pas, au-dessus des {typical} habituels à cette heure."
|
||||
}
|
||||
},
|
||||
"upcomingVisit": {
|
||||
"title": "Rendez-vous à venir",
|
||||
"bodyToday": "{what} — aujourd'hui",
|
||||
"bodyTomorrow": "{what} — demain"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -9715,7 +9708,8 @@
|
||||
"sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.",
|
||||
"scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.",
|
||||
"scopeCount": "{count} éléments issus de {groups} domaines",
|
||||
"scopeSensitive": "dont {names}"
|
||||
"scopeSensitive": "dont {names}",
|
||||
"leafVisits": "Consultations"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
@@ -9726,9 +9720,7 @@
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Ouvrir un dossier",
|
||||
"ownRecord": "Mon propre dossier",
|
||||
"readOnly": "Lecture seule",
|
||||
"canWrite": "Peut ajouter des entrées"
|
||||
"ownRecord": "Mon propre dossier"
|
||||
},
|
||||
"switch": {
|
||||
"failed": "Impossible d'ouvrir ce dossier. Veuillez réessayer.",
|
||||
@@ -10082,6 +10074,98 @@
|
||||
"hospital": "Hôpital",
|
||||
"therapy": "Séance de thérapie",
|
||||
"other": "Rendez-vous médical"
|
||||
},
|
||||
"sectionTitle": "Rendez-vous",
|
||||
"sectionDescription": "Consultations et rendez-vous pris, au même endroit.",
|
||||
"add": "Rendez-vous",
|
||||
"loadError": "Impossible de charger les rendez-vous.",
|
||||
"emptyTitle": "Aucun rendez-vous enregistré",
|
||||
"emptyDescription": "Une date suffit. Le reste est facultatif.",
|
||||
"upcomingHeading": "À venir",
|
||||
"pastHeading": "Passés",
|
||||
"createTitle": "Enregistrer un rendez-vous",
|
||||
"editTitle": "Modifier le rendez-vous",
|
||||
"formDescription": "Seule la date est obligatoire.",
|
||||
"created": "Rendez-vous enregistré.",
|
||||
"updated": "Rendez-vous mis à jour.",
|
||||
"saveFailed": "Le rendez-vous n'a pas pu être enregistré.",
|
||||
"checkupNotClosed": "Rendez-vous enregistré. Le dépistage reste ouvert : votre accès ne le couvre pas.",
|
||||
"status": {
|
||||
"planned": "Prévu",
|
||||
"done": "Effectué",
|
||||
"cancelled": "Annulé",
|
||||
"noShow": "Non honoré"
|
||||
},
|
||||
"card": {
|
||||
"documentCount": "documents liés",
|
||||
"labCount": "résultats de laboratoire liés",
|
||||
"conditionCount": "maladies liées"
|
||||
},
|
||||
"form": {
|
||||
"date": "Date et heure",
|
||||
"dateDerivesStatusHint": "Une date à venir correspond à un rendez-vous pris.",
|
||||
"status": "Statut",
|
||||
"practitioner": "Cabinet ou médecin",
|
||||
"practitionerHint": "Facultatif : vous pourrez ajouter le nom plus tard.",
|
||||
"practitionerNone": "Non précisé",
|
||||
"practitionerSearch": "Rechercher un nom ou un cabinet",
|
||||
"practitionerNoMatch": "Aucune entrée trouvée.",
|
||||
"practitionerAdd": "Créer une entrée",
|
||||
"practitionerClear": "Retirer la sélection",
|
||||
"kind": "Type",
|
||||
"reason": "Motif",
|
||||
"outcome": "Résultat",
|
||||
"outcomeReveal": "Ajouter un résultat",
|
||||
"linkDocuments": "Documents",
|
||||
"linkLabResults": "Résultats de laboratoire",
|
||||
"linkConditions": "Maladies",
|
||||
"linkNothingToOffer": "Rien à lier pour le moment."
|
||||
},
|
||||
"deleteTitle": "Supprimer ce rendez-vous ?",
|
||||
"deleteDescription": "L'entrée est supprimée. Si le rendez-vous est pris, son rappel disparaît avec elle.",
|
||||
"deleteFailed": "Le rendez-vous n'a pas pu être supprimé.",
|
||||
"suggestion": {
|
||||
"single": "Cela concerne-t-il ce rendez-vous ?",
|
||||
"choose": "À quel rendez-vous cela se rattache-t-il ?"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Rendez-vous liés",
|
||||
"emptyLine": "Aucun rendez-vous n'est encore rattaché à cette maladie.",
|
||||
"add": "Ajouter un rendez-vous"
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Prochain rendez-vous : {what} le {when}"
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Afficher les dépistages ou les rendez-vous",
|
||||
"tabVorsorge": "Dépistages",
|
||||
"tabVisits": "Rendez-vous"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Cabinets et médecins",
|
||||
"description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.",
|
||||
"linkLabel": "Carnet d'adresses",
|
||||
"add": "Entrée",
|
||||
"searchPlaceholder": "Rechercher un nom ou un cabinet",
|
||||
"loadError": "Impossible de charger le carnet d'adresses.",
|
||||
"emptyTitle": "Aucune entrée",
|
||||
"emptyDescription": "Ajoutez un cabinet pour pouvoir le choisir lors d'un rendez-vous.",
|
||||
"noMatchTitle": "Aucun résultat",
|
||||
"noMatchDescription": "Aucune entrée ne correspond à cette recherche.",
|
||||
"createTitle": "Ajouter une entrée",
|
||||
"editTitle": "Modifier l'entrée",
|
||||
"formDescription": "Seul le nom est obligatoire.",
|
||||
"name": "Nom",
|
||||
"specialty": "Spécialité",
|
||||
"practice": "Cabinet",
|
||||
"location": "Adresse",
|
||||
"phone": "Téléphone",
|
||||
"note": "Note",
|
||||
"noteHint": "Enregistrée chiffrée.",
|
||||
"saveFailed": "L'entrée n'a pas pu être enregistrée.",
|
||||
"deleteTitle": "Supprimer cette entrée ?",
|
||||
"deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.",
|
||||
"deleteFailed": "L'entrée n'a pas pu être supprimée."
|
||||
}
|
||||
}
|
||||
+151
-67
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Condizione",
|
||||
"familyHistoryColAgeAtOnset": "Età di esordio",
|
||||
"labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:",
|
||||
"labsCatalogRangeLabel": "intervallo salvato"
|
||||
"labsCatalogRangeLabel": "intervallo salvato",
|
||||
"visitsTitle": "Visite mediche",
|
||||
"visitsColDate": "Data",
|
||||
"visitsColPractice": "Studio",
|
||||
"visitsColKind": "Tipo",
|
||||
"visitsColReason": "Motivo",
|
||||
"visitsColOutcome": "Esito",
|
||||
"visitsColConditions": "Malattie"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -478,7 +485,8 @@
|
||||
"milestone": "Traguardi",
|
||||
"ecg_new_recording": "Nuove registrazioni ECG",
|
||||
"tension_window": "Periodi di tensione",
|
||||
"same_time_baseline": "Attività alla stessa ora"
|
||||
"same_time_baseline": "Attività alla stessa ora",
|
||||
"upcoming_visit": "Appuntamenti"
|
||||
},
|
||||
"layoutReset": "Ripristina i valori predefiniti",
|
||||
"layoutResetTitle": "Reimpostare il layout della dashboard?",
|
||||
@@ -1604,26 +1612,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Attiva la modalità Ricerca",
|
||||
"intro": "La modalità Ricerca aggiunge un grafico stimato del livello del farmaco alla tua pagina GLP-1. Leggi il limite qui sotto prima di attivarla: il grafico è uno strumento di apprendimento, mai una misura, mai un consiglio medico.",
|
||||
"whatItIsHeader": "Cosa è",
|
||||
"whatItIs": "Una curva a dente di sega che simula la sovrapposizione delle dosi registrate nel tempo, usando il modello Bateman a un compartimento con i parametri di assorbimento e di emivita pubblicati nell'EPAR EMA di ogni agonista GLP-1 approvato. L'asse verticale è privo di unità ed etichette. La forma, non il numero, è ciò che il grafico mostra.",
|
||||
"whatItIsntHeader": "Cosa non è",
|
||||
"whatItIsnt": "Non è un livello ematico. Non è una misura. Non è una previsione della risposta reale del tuo corpo. Non è una base per alcuna decisione di dose, di tempistica o di escalation. Per qualunque cosa riguardi come assumi il tuo farmaco, parla con il medico che te lo prescrive.",
|
||||
"whyEstimateHeader": "Perché è una stima",
|
||||
"whyEstimate": "La farmacocinetica di popolazione descrive il paziente tipico. La variabilità individuale è ampia: il modello Schneck/Urva 2024 del tirzepatide riporta circa il 22 % di variabilità interindividuale sull'assorbimento e circa il 49 % sul volume di distribuzione. Una curva da manuale non può catturare il tuo peso, la tua funzione renale, la tua tecnica di iniezione o la precisione degli orari. Leggi la curva come il ritmo del tuo dosaggio, non come il tuo livello.",
|
||||
"mdrBoundaryHeader": "Limite regolatorio",
|
||||
"mdrBoundary": "HealthLog mostra dati regolatori pubblici e le tue registrazioni. L'app non predice, non diagnostica, non tratta e non raccomanda alcuna terapia. Il Regolamento UE 2017/745 (Medical Device Regulation, MDR) e il documento di orientamento MDCG 2021-24 tracciano la linea sul software che interpreta dati per scopi medici: questa curva rimane decisamente sul lato educativo di quella linea. Se in qualunque momento ti sembra spingere verso una decisione clinica, chiudila.",
|
||||
"citationsHeader": "Fonti",
|
||||
"citations": "European Public Assessment Reports EMA per Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta e Bydureon (Sezione 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Versione del disclaimer: {version}",
|
||||
"acknowledgeCta": "Ho capito — attiva",
|
||||
"cancelCta": "Annulla",
|
||||
"successToast": "Modalità Ricerca attivata. La curva del livello del farmaco è ora visibile sulla tua pagina GLP-1.",
|
||||
"errorToast": "Impossibile attivare la modalità Ricerca. Riprova.",
|
||||
"staleVersionToast": "Il disclaimer è stato aggiornato dal caricamento di questa pagina. Ricarica per leggere la versione attuale."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Livello stimato del farmaco",
|
||||
"axisLabel": "Livello stimato (relativo)",
|
||||
@@ -3110,8 +3098,7 @@
|
||||
"valuesBack": "Torna a Insights",
|
||||
"valuesBackToMetric": "Torna a {metric}",
|
||||
"scoresBack": "Torna a Insights",
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo.",
|
||||
"ecgDisclaimer": "HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo."
|
||||
},
|
||||
"units": {
|
||||
"steps": "passi",
|
||||
@@ -3785,6 +3772,25 @@
|
||||
"statement": "Nei giorni con «{label}» l'umore è stato in media {direction}: {withAvg} contro {withoutAvg}.",
|
||||
"counts": "{withDays} giorni con, {withoutDays} giorni senza",
|
||||
"caveat": "Questo mostra una relazione, non una causa."
|
||||
},
|
||||
"prognosis": {
|
||||
"title": "La tua valutazione e ciò che suggeriscono le tue giornate passate",
|
||||
"selfLabel": "La tua valutazione",
|
||||
"expectedLabel": "Atteso in base alle tue abitudini",
|
||||
"band": "Intervallo da {low} a {high}",
|
||||
"statement": "In base alle tue abitudini finora, per {date} ci si sarebbe attesi un valore intorno a {value}. Ricavato da {n} giornate che contengono tutto ciò che serve al confronto.",
|
||||
"provisional": "Ancora provvisorio: lo schema si appoggia su poche giornate.",
|
||||
"notToday": "Questo descrive {date}, non oggi.",
|
||||
"deviationWithin": "La tua valutazione rientra in questo intervallo.",
|
||||
"deviationAbove": "La tua valutazione è {amount} punti sopra questo intervallo.",
|
||||
"deviationBelow": "La tua valutazione è {amount} punti sotto questo intervallo.",
|
||||
"weightedTitle": "Che cosa ha pesato di più nel confronto di questa giornata",
|
||||
"weightedUp": "{label} — contava verso un valore atteso più alto",
|
||||
"weightedDown": "{label} — contava verso un valore atteso più basso",
|
||||
"caveat": "È un confronto con le tue giornate passate, non un'affermazione su come una giornata sia andata a finire così.",
|
||||
"learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.",
|
||||
"noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Registrare il contesto della giornata rende confrontabile una parte maggiore.",
|
||||
"featureValue": "{field}: {value}"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -4255,10 +4261,8 @@
|
||||
},
|
||||
"ecg": {
|
||||
"sectionTitle": "Registrazioni ECG",
|
||||
"sectionIntro": "Tracciati ECG a derivazione singola registrati dal tuo dispositivo. HealthLog mostra solo la registrazione e il risultato prodotto dal dispositivo; non legge né interpreta il tracciato.",
|
||||
"loadError": "Impossibile caricare le tue registrazioni ECG.",
|
||||
"backToList": "Torna alle registrazioni",
|
||||
"disclaimer": "Questo è il risultato prodotto dal tuo dispositivo di registrazione. HealthLog non legge né interpreta le registrazioni ECG e non fornisce una diagnosi.",
|
||||
"clinicianNote": "Se questo risultato ti preoccupa, parla di questa registrazione con un medico.",
|
||||
"resultAttribution": "Risultato registrato: {result}, come riportato dal dispositivo di registrazione.",
|
||||
"result": {
|
||||
@@ -4591,19 +4595,6 @@
|
||||
"remainingMany": "Mancano {count} misurazioni al trend."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Modalità Ricerca",
|
||||
"subtitle": "Curva opzionale del livello stimato di GLP-1, da dati pubblicati dall'EMA.",
|
||||
"toggleLabel": "Mostra la curva stimata del livello del farmaco",
|
||||
"disabledStatus": "Disattivata. La curva è nascosta.",
|
||||
"enabledStaleStatus": "Il disclaimer è stato aggiornato. Riconferma sotto per rivedere la curva.",
|
||||
"acknowledgedOn": "Confermato il {date}.",
|
||||
"rePromptTitle": "Disclaimer aggiornato",
|
||||
"rePromptBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo e riconferma per mantenere visibile la curva.",
|
||||
"rePromptCta": "Riconferma disclaimer",
|
||||
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova.",
|
||||
"subtitleDetail": "Solo lettura: mai una misurazione né un consiglio medico."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sezioni delle impostazioni",
|
||||
"groups": {
|
||||
@@ -4887,8 +4878,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avanzate",
|
||||
"description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute.",
|
||||
"subtitle": "Modalità ricerca, ripristino dati ed eliminazione account."
|
||||
"description": "La via d'uscita: cancella per sempre tutti i dati di salute o chiudi l'account.",
|
||||
"subtitle": "Ripristino dati ed eliminazione account."
|
||||
},
|
||||
"export": {
|
||||
"title": "Esporta e importa",
|
||||
@@ -5977,7 +5968,9 @@
|
||||
"rangeCustomEnd": "Al",
|
||||
"errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.",
|
||||
"errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.",
|
||||
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account."
|
||||
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.",
|
||||
"visitOffer": "Il {when} c'è un appuntamento: {what}.",
|
||||
"visitOfferApply": "Referto fino a quella data"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
|
||||
"globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.",
|
||||
@@ -6037,14 +6030,6 @@
|
||||
"createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato."
|
||||
},
|
||||
"genderOther": "Altro",
|
||||
"cycleTracking": {
|
||||
"title": "Monitoraggio del ciclo",
|
||||
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi.",
|
||||
"enable": "Attiva il monitoraggio del ciclo",
|
||||
"manage": "Apri il monitoraggio del ciclo",
|
||||
"error": "Impossibile aggiornare il monitoraggio del ciclo.",
|
||||
"detail": "Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.",
|
||||
@@ -6285,9 +6270,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Impostazione privacy",
|
||||
"description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.",
|
||||
"openAi": "Modalità privacy IA",
|
||||
"openResearch": "Modalità ricerca"
|
||||
"description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA.",
|
||||
"openAi": "Modalità privacy IA"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -8153,7 +8137,8 @@
|
||||
"openLink": "Apri controlli"
|
||||
},
|
||||
"showMeasurements": "Mostra le misurazioni",
|
||||
"loadError": "Impossibile caricare i promemoria."
|
||||
"loadError": "Impossibile caricare i promemoria.",
|
||||
"fileVisit": "Registra un appuntamento"
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Vista",
|
||||
@@ -8506,27 +8491,27 @@
|
||||
},
|
||||
"mcp": {
|
||||
"label": "Accesso assistente esterno (MCP)",
|
||||
"description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita."
|
||||
"description": "Accesso in lettura per un assistente compatibile con MCP, tramite un token limitato."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Ambiente",
|
||||
"description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default."
|
||||
"description": "Meteo e ore di luce giornaliere della tua posizione, accanto a umore, sonno e parametri vitali."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Documenti",
|
||||
"description": "Conserva ogni lettera medica, referto e scansione in un unico archivio cifrato e ricercabile — collegato alle tue patologie. Disattivato di default."
|
||||
"description": "Lettere mediche, referti e scansioni in un archivio cifrato e ricercabile."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Benessere mentale",
|
||||
"description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA."
|
||||
"description": "Quattro questionari facoltativi, accanto al diario dell'umore."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Assunzione di nutrienti",
|
||||
"description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita."
|
||||
"description": "Totali giornalieri di micronutrienti sincronizzati da Apple Health."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Vaccinazioni",
|
||||
"description": "Il libretto vaccinale in forma strutturata: quale dose è stata somministrata e quando, in che punto della serie si trova e quando è previsto il prossimo richiamo."
|
||||
"description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9023,7 +9008,8 @@
|
||||
},
|
||||
"openDetail": "Mostra dettagli",
|
||||
"historyLoadError": "Non è stato possibile caricare la cronologia dei test",
|
||||
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova."
|
||||
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.",
|
||||
"privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA."
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
@@ -9310,7 +9296,8 @@
|
||||
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
|
||||
"uploadedMeta": "Aggiunto il {date} · {size}",
|
||||
"share": "Condividi",
|
||||
"labValuesLink": "Vedi {count} valori di laboratorio"
|
||||
"labValuesLink": "Vedi {count} valori di laboratorio",
|
||||
"visitsLabel": "Appartiene all'appuntamento"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Rilascia i file per caricarli",
|
||||
@@ -9327,7 +9314,8 @@
|
||||
"partialFailure": "Impossibile aggiornare {failed} su {total}",
|
||||
"failed": "Impossibile completare l'azione",
|
||||
"share": "Condividi",
|
||||
"shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova."
|
||||
"shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova.",
|
||||
"linkVisit": "Assegna a un appuntamento"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documenti",
|
||||
@@ -9620,6 +9608,11 @@
|
||||
"below": "Alle {time} sei a {today} passi. In una giornata tipo a quest'ora sei intorno a {typical}.",
|
||||
"above": "Alle {time} sei a {today} passi, sopra i {typical} che per te sono abituali a quest'ora."
|
||||
}
|
||||
},
|
||||
"upcomingVisit": {
|
||||
"title": "Appuntamento in arrivo",
|
||||
"bodyToday": "{what} — oggi",
|
||||
"bodyTomorrow": "{what} — domani"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -9715,7 +9708,8 @@
|
||||
"sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.",
|
||||
"scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.",
|
||||
"scopeCount": "{count} voci da {groups} aree",
|
||||
"scopeSensitive": "incl. {names}"
|
||||
"scopeSensitive": "incl. {names}",
|
||||
"leafVisits": "Visite mediche"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
@@ -9726,9 +9720,7 @@
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Apri una cartella",
|
||||
"ownRecord": "La mia cartella",
|
||||
"readOnly": "Sola lettura",
|
||||
"canWrite": "Può aggiungere voci"
|
||||
"ownRecord": "La mia cartella"
|
||||
},
|
||||
"switch": {
|
||||
"failed": "Non è stato possibile aprire quella cartella. Riprova.",
|
||||
@@ -10082,6 +10074,98 @@
|
||||
"hospital": "Ospedale",
|
||||
"therapy": "Seduta di terapia",
|
||||
"other": "Appuntamento medico"
|
||||
},
|
||||
"sectionTitle": "Appuntamenti",
|
||||
"sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.",
|
||||
"add": "Appuntamento",
|
||||
"loadError": "Impossibile caricare gli appuntamenti.",
|
||||
"emptyTitle": "Nessun appuntamento registrato",
|
||||
"emptyDescription": "Basta una data. Tutto il resto è facoltativo.",
|
||||
"upcomingHeading": "In arrivo",
|
||||
"pastHeading": "Passati",
|
||||
"createTitle": "Registra un appuntamento",
|
||||
"editTitle": "Modifica appuntamento",
|
||||
"formDescription": "Solo la data è obbligatoria.",
|
||||
"created": "Appuntamento salvato.",
|
||||
"updated": "Appuntamento aggiornato.",
|
||||
"saveFailed": "Non è stato possibile salvare l'appuntamento.",
|
||||
"checkupNotClosed": "Appuntamento salvato. Il controllo resta aperto: il tuo accesso non lo comprende.",
|
||||
"status": {
|
||||
"planned": "Previsto",
|
||||
"done": "Effettuato",
|
||||
"cancelled": "Annullato",
|
||||
"noShow": "Non presentato"
|
||||
},
|
||||
"card": {
|
||||
"documentCount": "documenti collegati",
|
||||
"labCount": "esami di laboratorio collegati",
|
||||
"conditionCount": "malattie collegate"
|
||||
},
|
||||
"form": {
|
||||
"date": "Data e ora",
|
||||
"dateDerivesStatusHint": "Una data futura è un appuntamento fissato.",
|
||||
"status": "Stato",
|
||||
"practitioner": "Studio o medico",
|
||||
"practitionerHint": "Facoltativo: puoi aggiungere il nome più tardi.",
|
||||
"practitionerNone": "Non indicato",
|
||||
"practitionerSearch": "Cerca nome o studio",
|
||||
"practitionerNoMatch": "Nessuna voce trovata.",
|
||||
"practitionerAdd": "Aggiungi nuova",
|
||||
"practitionerClear": "Rimuovi la selezione",
|
||||
"kind": "Tipo",
|
||||
"reason": "Motivo",
|
||||
"outcome": "Esito",
|
||||
"outcomeReveal": "Aggiungi un esito",
|
||||
"linkDocuments": "Documenti",
|
||||
"linkLabResults": "Esami di laboratorio",
|
||||
"linkConditions": "Malattie",
|
||||
"linkNothingToOffer": "Per ora non c'è nulla da collegare."
|
||||
},
|
||||
"deleteTitle": "Eliminare questo appuntamento?",
|
||||
"deleteDescription": "La voce viene rimossa. Se l'appuntamento è fissato, anche il promemoria sparisce.",
|
||||
"deleteFailed": "Non è stato possibile eliminare l'appuntamento.",
|
||||
"suggestion": {
|
||||
"single": "Appartiene a questo appuntamento?",
|
||||
"choose": "A quale appuntamento appartiene?"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Appuntamenti collegati",
|
||||
"emptyLine": "Nessun appuntamento ancora collegato a questa malattia.",
|
||||
"add": "Aggiungi un appuntamento"
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Prossimo appuntamento: {what} il {when}"
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Mostra controlli o appuntamenti",
|
||||
"tabVorsorge": "Controlli",
|
||||
"tabVisits": "Appuntamenti"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Studi e medici",
|
||||
"description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.",
|
||||
"linkLabel": "Rubrica",
|
||||
"add": "Voce",
|
||||
"searchPlaceholder": "Cerca nome o studio",
|
||||
"loadError": "Impossibile caricare la rubrica.",
|
||||
"emptyTitle": "Nessuna voce",
|
||||
"emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.",
|
||||
"noMatchTitle": "Nessuna corrispondenza",
|
||||
"noMatchDescription": "Nessuna voce corrisponde a questa ricerca.",
|
||||
"createTitle": "Aggiungi una voce",
|
||||
"editTitle": "Modifica la voce",
|
||||
"formDescription": "Solo il nome è obbligatorio.",
|
||||
"name": "Nome",
|
||||
"specialty": "Specializzazione",
|
||||
"practice": "Studio",
|
||||
"location": "Indirizzo",
|
||||
"phone": "Telefono",
|
||||
"note": "Nota",
|
||||
"noteHint": "Salvata cifrata.",
|
||||
"saveFailed": "Non è stato possibile salvare la voce.",
|
||||
"deleteTitle": "Eliminare questa voce?",
|
||||
"deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.",
|
||||
"deleteFailed": "Non è stato possibile eliminare la voce."
|
||||
}
|
||||
}
|
||||
+151
-67
@@ -283,7 +283,14 @@
|
||||
"familyHistoryColCondition": "Schorzenie",
|
||||
"familyHistoryColAgeAtOnset": "Wiek zachorowania",
|
||||
"labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:",
|
||||
"labsCatalogRangeLabel": "zapisany zakres"
|
||||
"labsCatalogRangeLabel": "zapisany zakres",
|
||||
"visitsTitle": "Wizyty lekarskie",
|
||||
"visitsColDate": "Data",
|
||||
"visitsColPractice": "Gabinet",
|
||||
"visitsColKind": "Rodzaj",
|
||||
"visitsColReason": "Powód",
|
||||
"visitsColOutcome": "Wynik",
|
||||
"visitsColConditions": "Choroby"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -478,7 +485,8 @@
|
||||
"milestone": "Kamienie milowe",
|
||||
"ecg_new_recording": "Nowe zapisy EKG",
|
||||
"tension_window": "Okresy napięcia",
|
||||
"same_time_baseline": "Aktywność o tej samej porze"
|
||||
"same_time_baseline": "Aktywność o tej samej porze",
|
||||
"upcoming_visit": "Wizyty"
|
||||
},
|
||||
"layoutReset": "Przywróć wartości domyślne",
|
||||
"layoutResetTitle": "Zresetować układ pulpitu?",
|
||||
@@ -1604,26 +1612,6 @@
|
||||
}
|
||||
},
|
||||
"researchMode": {
|
||||
"dialog": {
|
||||
"title": "Włącz tryb Badawczy",
|
||||
"intro": "Tryb Badawczy dodaje szacowany wykres poziomu leku do twojej strony GLP-1. Przed jego włączeniem przeczytaj granicę poniżej — wykres jest pomocą edukacyjną, nigdy pomiarem i nigdy poradą medyczną.",
|
||||
"whatItIsHeader": "Czym to jest",
|
||||
"whatItIs": "Piłokształtną krzywą, która symuluje nakładanie się twoich zarejestrowanych dawek w czasie, na podstawie jednokompartmentowego modelu Batemana z parametrami absorpcji i okresu półtrwania opublikowanymi w EPAR EMA dla każdego zatwierdzonego agonisty GLP-1. Oś pionowa jest bezjednostkowa i nieopisana. To kształt, a nie liczba, jest tym, co pokazuje wykres.",
|
||||
"whatItIsntHeader": "Czym to nie jest",
|
||||
"whatItIsnt": "Nie jest poziomem we krwi. Nie jest pomiarem. Nie jest prognozą faktycznej reakcji twojego organizmu. Nie jest podstawą żadnej decyzji o dawce, czasie ani eskalacji. We wszystkim, co dotyczy sposobu przyjmowania leku, porozmawiaj z lekarzem prowadzącym.",
|
||||
"whyEstimateHeader": "Dlaczego to oszacowanie",
|
||||
"whyEstimate": "Farmakokinetyka populacyjna opisuje typowego pacjenta. Zmienność osobnicza jest duża — model Schneck/Urva 2024 dla tirzepatydu raportuje około 22 % międzyosobniczej zmienności absorpcji i około 49 % objętości dystrybucji. Krzywa z podręcznika nie uchwyci ani twojej masy ciała, ani funkcji nerek, ani techniki iniekcji, ani precyzji czasu. Czytaj krzywą jako rytm dawkowania, a nie twój poziom.",
|
||||
"mdrBoundaryHeader": "Granica regulacyjna",
|
||||
"mdrBoundary": "HealthLog wyświetla publiczne dane regulacyjne i twoje własne wpisy. Aplikacja nie prognozuje, nie diagnozuje, nie leczy i nie rekomenduje żadnej terapii. Rozporządzenie UE 2017/745 (Medical Device Regulation, MDR) oraz dokument MDCG 2021-24 wyznaczają granicę przy oprogramowaniu interpretującym dane do celów medycznych — ten wykres pozostaje zdecydowanie po edukacyjnej stronie tej granicy. Jeśli kiedykolwiek poczujesz, że kieruje cię w stronę decyzji klinicznej, zamknij go.",
|
||||
"citationsHeader": "Źródła",
|
||||
"citations": "European Public Assessment Reports EMA dla Mounjaro, Ozempic, Wegovy, Saxenda, Trulicity, Byetta i Bydureon (Sekcja 5.2). Schneck K, Urva S. Population pharmacokinetics of the GIP/GLP receptor agonist tirzepatide. CPT Pharmacometrics Syst Pharmacol 2024;13(3):494-503 (DOI 10.1002/psp4.13099).",
|
||||
"versionLine": "Wersja disclaimera: {version}",
|
||||
"acknowledgeCta": "Rozumiem — włącz",
|
||||
"cancelCta": "Anuluj",
|
||||
"successToast": "Tryb Badawczy włączony. Krzywa poziomu leku jest teraz widoczna na twojej stronie GLP-1.",
|
||||
"errorToast": "Nie udało się włączyć trybu Badawczego. Spróbuj ponownie.",
|
||||
"staleVersionToast": "Disclaimer został zaktualizowany od czasu wczytania tej strony. Odśwież, aby przeczytać aktualną wersję."
|
||||
},
|
||||
"chart": {
|
||||
"title": "Szacowany poziom leku",
|
||||
"axisLabel": "Szacowany poziom (względny)",
|
||||
@@ -3110,8 +3098,7 @@
|
||||
"valuesBack": "Powrót do Insights",
|
||||
"valuesBackToMetric": "Powrót do {metric}",
|
||||
"scoresBack": "Powrót do Insights",
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia.",
|
||||
"ecgDisclaimer": "HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia."
|
||||
},
|
||||
"units": {
|
||||
"steps": "kroki",
|
||||
@@ -3785,6 +3772,25 @@
|
||||
"statement": "W dniach z „{label}” nastrój był średnio {direction}: {withAvg} wobec {withoutAvg}.",
|
||||
"counts": "{withDays} dni z, {withoutDays} dni bez",
|
||||
"caveat": "To pokazuje związek, nie przyczynę."
|
||||
},
|
||||
"prognosis": {
|
||||
"title": "Twoja ocena i to, co sugerują twoje dotychczasowe dni",
|
||||
"selfLabel": "Twoja ocena",
|
||||
"expectedLabel": "Oczekiwane na podstawie twoich wzorców",
|
||||
"band": "Zakres od {low} do {high}",
|
||||
"statement": "Na podstawie twoich dotychczasowych wzorców dla {date} należałoby oczekiwać wartości około {value}. Wyliczone z {n} dni, które zawierają wszystko, czego potrzebuje porównanie.",
|
||||
"provisional": "Jeszcze wstępne: wzorzec opiera się na krótkim odcinku dni.",
|
||||
"notToday": "To opisuje {date}, nie dzisiaj.",
|
||||
"deviationWithin": "Twoja własna ocena mieści się w tym zakresie.",
|
||||
"deviationAbove": "Twoja własna ocena leży {amount} punktów powyżej tego zakresu.",
|
||||
"deviationBelow": "Twoja własna ocena leży {amount} punktów poniżej tego zakresu.",
|
||||
"weightedTitle": "Co najbardziej zaważyło w porównaniu tego dnia",
|
||||
"weightedUp": "{label} — liczyło się w stronę wyższej oczekiwanej wartości",
|
||||
"weightedDown": "{label} — liczyło się w stronę niższej oczekiwanej wartości",
|
||||
"caveat": "To porównanie z twoimi wcześniejszymi dniami, nie stwierdzenie, jak doszło do takiego przebiegu dnia.",
|
||||
"learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.",
|
||||
"noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z tym dzień. Zapisywanie kontekstu dnia sprawia, że więcej da się porównać.",
|
||||
"featureValue": "{field}: {value}"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -4255,10 +4261,8 @@
|
||||
},
|
||||
"ecg": {
|
||||
"sectionTitle": "Zapisy EKG",
|
||||
"sectionIntro": "Jednoodprowadzeniowe zapisy EKG zarejestrowane przez Twoje urządzenie. HealthLog pokazuje tylko zapis i wynik wygenerowany przez urządzenie — nie odczytuje ani nie interpretuje przebiegu.",
|
||||
"loadError": "Nie udało się wczytać Twoich zapisów EKG.",
|
||||
"backToList": "Powrót do zapisów",
|
||||
"disclaimer": "To jest wynik wygenerowany przez Twoje urządzenie rejestrujące. HealthLog nie odczytuje ani nie interpretuje zapisów EKG i nie stawia diagnozy.",
|
||||
"clinicianNote": "Jeśli ten wynik Cię niepokoi, omów ten zapis z lekarzem.",
|
||||
"resultAttribution": "Zarejestrowany wynik: {result}, zgodnie z informacją z urządzenia rejestrującego.",
|
||||
"result": {
|
||||
@@ -4591,19 +4595,6 @@
|
||||
"remainingMany": "Jeszcze {count} pomiarów do trendu."
|
||||
},
|
||||
"settings": {
|
||||
"researchMode": {
|
||||
"sectionTitle": "Tryb Badawczy",
|
||||
"subtitle": "Opcjonalna krzywa szacowanego poziomu leku GLP-1 wg danych EMA.",
|
||||
"toggleLabel": "Pokaż szacowaną krzywą poziomu leku",
|
||||
"disabledStatus": "Wyłączone. Krzywa jest ukryta.",
|
||||
"enabledStaleStatus": "Disclaimer został zaktualizowany. Potwierdź ponownie poniżej, aby przywrócić krzywą.",
|
||||
"acknowledgedOn": "Potwierdzono {date}.",
|
||||
"rePromptTitle": "Zaktualizowano disclaimer",
|
||||
"rePromptBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie i potwierdź, aby krzywa pozostała widoczna.",
|
||||
"rePromptCta": "Potwierdź ponownie disclaimer",
|
||||
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie.",
|
||||
"subtitleDetail": "Tylko do odczytu — nigdy pomiar ani porada medyczna."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sekcje ustawień",
|
||||
"groups": {
|
||||
@@ -4887,8 +4878,8 @@
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Zaawansowane",
|
||||
"description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych.",
|
||||
"subtitle": "Tryb badawczy, reset danych i usunięcie konta."
|
||||
"description": "Wyjście awaryjne: trwale usuń wszystkie dane zdrowotne albo zamknij konto.",
|
||||
"subtitle": "Reset danych i usunięcie konta."
|
||||
},
|
||||
"export": {
|
||||
"title": "Eksport i import",
|
||||
@@ -5977,7 +5968,9 @@
|
||||
"rangeCustomEnd": "Do",
|
||||
"errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.",
|
||||
"errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.",
|
||||
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta."
|
||||
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.",
|
||||
"visitOffer": "{when} zaplanowano wizytę: {what}.",
|
||||
"visitOfferApply": "Raport do tego dnia"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
|
||||
"globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.",
|
||||
@@ -6037,14 +6030,6 @@
|
||||
"createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać."
|
||||
},
|
||||
"genderOther": "Inna",
|
||||
"cycleTracking": {
|
||||
"title": "Śledzenie cyklu",
|
||||
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz.",
|
||||
"enable": "Włącz śledzenie cyklu",
|
||||
"manage": "Otwórz śledzenie cyklu",
|
||||
"error": "Nie udało się zaktualizować śledzenia cyklu.",
|
||||
"detail": "Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
|
||||
@@ -6285,9 +6270,8 @@
|
||||
},
|
||||
"posture": {
|
||||
"title": "Ustawienia prywatności",
|
||||
"description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.",
|
||||
"openAi": "Tryb prywatności AI",
|
||||
"openResearch": "Tryb badawczy"
|
||||
"description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI.",
|
||||
"openAi": "Tryb prywatności AI"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -8153,7 +8137,8 @@
|
||||
"openLink": "Otwórz badania"
|
||||
},
|
||||
"showMeasurements": "Pokaż pomiary",
|
||||
"loadError": "Nie udało się załadować przypomnień."
|
||||
"loadError": "Nie udało się załadować przypomnień.",
|
||||
"fileVisit": "Zapisz wizytę"
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Widok",
|
||||
@@ -8506,27 +8491,27 @@
|
||||
},
|
||||
"mcp": {
|
||||
"label": "Dostęp asystenta zewnętrznego (MCP)",
|
||||
"description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone."
|
||||
"description": "Dostęp do odczytu dla asystenta obsługującego MCP, przez token o ograniczonym zakresie."
|
||||
},
|
||||
"environment": {
|
||||
"label": "Środowisko",
|
||||
"description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone."
|
||||
"description": "Dzienna pogoda i długość dnia dla Twojej lokalizacji, obok nastroju, snu i parametrów życiowych."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Dokumenty",
|
||||
"description": "Przechowuj każde pismo lekarskie, wynik i skan w jednym zaszyfrowanym, przeszukiwalnym miejscu — powiązane z Twoimi schorzeniami. Domyślnie wyłączone."
|
||||
"description": "Pisma lekarskie, wyniki i skany w jednym zaszyfrowanym, przeszukiwalnym miejscu."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Dobrostan psychiczny",
|
||||
"description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI."
|
||||
"description": "Cztery dobrowolne testy obok dziennika nastroju."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Spożycie składników odżywczych",
|
||||
"description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone."
|
||||
"description": "Dzienne sumy mikroskładników synchronizowane z Apple Health."
|
||||
},
|
||||
"vaccinations": {
|
||||
"label": "Szczepienia",
|
||||
"description": "Książeczka szczepień w formie uporządkowanej: która dawka i kiedy została podana, w którym miejscu serii się znajduje i kiedy przypada następne przypomnienie."
|
||||
"description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -9023,7 +9008,8 @@
|
||||
},
|
||||
"openDetail": "Pokaż szczegóły",
|
||||
"historyLoadError": "Nie udało się wczytać historii testów",
|
||||
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie."
|
||||
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie.",
|
||||
"privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI."
|
||||
},
|
||||
"records": {
|
||||
"aiInclusion": {
|
||||
@@ -9310,7 +9296,8 @@
|
||||
"previewUnavailable": "Brak podglądu dla tego typu pliku",
|
||||
"uploadedMeta": "Dodano {date} · {size}",
|
||||
"share": "Udostępnij",
|
||||
"labValuesLink": "Zobacz {count} wyników laboratoryjnych"
|
||||
"labValuesLink": "Zobacz {count} wyników laboratoryjnych",
|
||||
"visitsLabel": "Należy do wizyty"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Upuść pliki, aby je przesłać",
|
||||
@@ -9327,7 +9314,8 @@
|
||||
"partialFailure": "Nie udało się zaktualizować {failed} z {total}",
|
||||
"failed": "Nie udało się wykonać akcji",
|
||||
"share": "Udostępnij",
|
||||
"shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie."
|
||||
"shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie.",
|
||||
"linkVisit": "Przypisz do wizyty"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Dokumenty",
|
||||
@@ -9620,6 +9608,11 @@
|
||||
"below": "Do {time} masz {today} kroków. W zwykły dzień o tej porze jesteś w okolicach {typical}.",
|
||||
"above": "Do {time} masz {today} kroków, powyżej zwykłych {typical} o tej porze."
|
||||
}
|
||||
},
|
||||
"upcomingVisit": {
|
||||
"title": "Zbliża się wizyta",
|
||||
"bodyToday": "{what} — dzisiaj",
|
||||
"bodyTomorrow": "{what} — jutro"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -9715,7 +9708,8 @@
|
||||
"sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.",
|
||||
"scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.",
|
||||
"scopeCount": "{count} pozycji z {groups} obszarów",
|
||||
"scopeSensitive": "w tym {names}"
|
||||
"scopeSensitive": "w tym {names}",
|
||||
"leafVisits": "Wizyty lekarskie"
|
||||
},
|
||||
"recordSharing": {
|
||||
"banner": {
|
||||
@@ -9726,9 +9720,7 @@
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Otwórz kartę",
|
||||
"ownRecord": "Moja własna karta",
|
||||
"readOnly": "Tylko odczyt",
|
||||
"canWrite": "Może dodawać wpisy"
|
||||
"ownRecord": "Moja własna karta"
|
||||
},
|
||||
"switch": {
|
||||
"failed": "Nie udało się otworzyć tej karty. Spróbuj ponownie.",
|
||||
@@ -10082,6 +10074,98 @@
|
||||
"hospital": "Szpital",
|
||||
"therapy": "Sesja terapeutyczna",
|
||||
"other": "Wizyta lekarska"
|
||||
},
|
||||
"sectionTitle": "Wizyty",
|
||||
"sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.",
|
||||
"add": "Wizyta",
|
||||
"loadError": "Nie udało się wczytać wizyt.",
|
||||
"emptyTitle": "Brak zapisanych wizyt",
|
||||
"emptyDescription": "Wystarczy data. Reszta jest opcjonalna.",
|
||||
"upcomingHeading": "Nadchodzące",
|
||||
"pastHeading": "Minione",
|
||||
"createTitle": "Zapisz wizytę",
|
||||
"editTitle": "Edytuj wizytę",
|
||||
"formDescription": "Wymagana jest tylko data.",
|
||||
"created": "Wizyta zapisana.",
|
||||
"updated": "Wizyta zaktualizowana.",
|
||||
"saveFailed": "Nie udało się zapisać wizyty.",
|
||||
"checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte — twój dostęp tego nie obejmuje.",
|
||||
"status": {
|
||||
"planned": "Zaplanowana",
|
||||
"done": "Odbyta",
|
||||
"cancelled": "Odwołana",
|
||||
"noShow": "Niewykorzystana"
|
||||
},
|
||||
"card": {
|
||||
"documentCount": "powiązane dokumenty",
|
||||
"labCount": "powiązane wyniki badań",
|
||||
"conditionCount": "powiązane choroby"
|
||||
},
|
||||
"form": {
|
||||
"date": "Data i godzina",
|
||||
"dateDerivesStatusHint": "Data w przyszłości oznacza umówiony termin.",
|
||||
"status": "Status",
|
||||
"practitioner": "Gabinet lub lekarz",
|
||||
"practitionerHint": "Opcjonalne — nazwę możesz dodać później.",
|
||||
"practitionerNone": "Nie podano",
|
||||
"practitionerSearch": "Szukaj nazwiska lub gabinetu",
|
||||
"practitionerNoMatch": "Nie znaleziono wpisu.",
|
||||
"practitionerAdd": "Dodaj nowy",
|
||||
"practitionerClear": "Usuń wybór",
|
||||
"kind": "Rodzaj",
|
||||
"reason": "Powód",
|
||||
"outcome": "Wynik",
|
||||
"outcomeReveal": "Dodaj wynik",
|
||||
"linkDocuments": "Dokumenty",
|
||||
"linkLabResults": "Wyniki badań",
|
||||
"linkConditions": "Choroby",
|
||||
"linkNothingToOffer": "Na razie nie ma nic do powiązania."
|
||||
},
|
||||
"deleteTitle": "Usunąć tę wizytę?",
|
||||
"deleteDescription": "Wpis zostanie usunięty. Jeśli wizyta jest umówiona, przypomnienie o niej też zniknie.",
|
||||
"deleteFailed": "Nie udało się usunąć wizyty.",
|
||||
"suggestion": {
|
||||
"single": "Czy to należy do tej wizyty?",
|
||||
"choose": "Do której wizyty to należy?"
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Powiązane wizyty",
|
||||
"emptyLine": "Do tej choroby nie przypisano jeszcze żadnej wizyty.",
|
||||
"add": "Dodaj wizytę"
|
||||
},
|
||||
"dashboard": {
|
||||
"nextVisit": "Najbliższa wizyta: {what}, {when}"
|
||||
}
|
||||
},
|
||||
"checkups": {
|
||||
"viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty",
|
||||
"tabVorsorge": "Profilaktyka",
|
||||
"tabVisits": "Wizyty"
|
||||
},
|
||||
"practitioners": {
|
||||
"title": "Gabinety i lekarze",
|
||||
"description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.",
|
||||
"linkLabel": "Książka adresowa",
|
||||
"add": "Wpis",
|
||||
"searchPlaceholder": "Szukaj nazwiska lub gabinetu",
|
||||
"loadError": "Nie udało się wczytać książki adresowej.",
|
||||
"emptyTitle": "Brak wpisów",
|
||||
"emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.",
|
||||
"noMatchTitle": "Brak wyników",
|
||||
"noMatchDescription": "Żaden wpis nie pasuje do tego wyszukiwania.",
|
||||
"createTitle": "Dodaj wpis",
|
||||
"editTitle": "Edytuj wpis",
|
||||
"formDescription": "Wymagana jest tylko nazwa.",
|
||||
"name": "Nazwa",
|
||||
"specialty": "Specjalizacja",
|
||||
"practice": "Gabinet",
|
||||
"location": "Adres",
|
||||
"phone": "Telefon",
|
||||
"note": "Notatka",
|
||||
"noteHint": "Zapisywana w postaci zaszyfrowanej.",
|
||||
"saveFailed": "Nie udało się zapisać wpisu.",
|
||||
"deleteTitle": "Usunąć ten wpis?",
|
||||
"deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.",
|
||||
"deleteFailed": "Nie udało się usunąć wpisu."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
-- v1.38 — the expected value of a day, beside the day's own rating.
|
||||
--
|
||||
-- One row per account per local day, holding what the account's own past days
|
||||
-- say a day like this one usually looks like. It is a second reading of a day,
|
||||
-- never a correction of the first: the self-assessment on `mood_entries` is
|
||||
-- stored exactly as the person set it and nothing here touches it. The
|
||||
-- distance between the two is the whole point of having both.
|
||||
--
|
||||
-- Its own table rather than four more columns on `mood_entries`, and the
|
||||
-- reason is a permission rather than a shape: a value the user must not be
|
||||
-- able to overwrite has no business sitting in the row their own edit form
|
||||
-- writes. One writer, one table.
|
||||
--
|
||||
-- Not carried in a disaster-recovery backup. Every value here is recomputable
|
||||
-- from `mood_entries` + `mood_contexts` and the measurement tables, and the
|
||||
-- nightly job rebuilds it. The verdict is written down in `DERIVED_MODELS`
|
||||
-- (`src/lib/export/backup-plan.ts`) where the completeness guard reads it,
|
||||
-- so a restore that comes back without these rows is doing what it was told.
|
||||
--
|
||||
-- No CHECK constraints on the ranges, matching `mood_contexts`: the bounds are
|
||||
-- server-side, and a later change to a scale must not strand rows written
|
||||
-- under the old one.
|
||||
|
||||
CREATE TABLE "mood_predictions" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
-- YYYY-MM-DD in the account's own zone, the same key `mood_entries.date`
|
||||
-- carries, so the two readings of one day join without a second timezone
|
||||
-- derivation.
|
||||
"date" TEXT NOT NULL,
|
||||
|
||||
-- The expected pleasantness (A1), 0-10, and the band around it. The band
|
||||
-- comes from the rolling-origin validation residuals, so it widens when
|
||||
-- the model is bad instead of staying confidently narrow.
|
||||
"predicted" DOUBLE PRECISION NOT NULL,
|
||||
"ci_low" DOUBLE PRECISION,
|
||||
"ci_high" DOUBLE PRECISION,
|
||||
-- Complete days the fit was built from. Displayed wherever the value is.
|
||||
"n" INTEGER NOT NULL,
|
||||
|
||||
-- Bumped when the feature set or the fit changes, so a re-fit is legible
|
||||
-- in the stored rows rather than silently changing what they mean.
|
||||
"model_version" TEXT NOT NULL,
|
||||
-- JSON array of { feature, contribution }: the standardised coefficient
|
||||
-- times that day's standardised value. Stored so the explanation line is
|
||||
-- generated from what the model actually used.
|
||||
"features" TEXT NOT NULL,
|
||||
"computed_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "mood_predictions_pkey" PRIMARY KEY ("user_id", "date")
|
||||
);
|
||||
|
||||
-- The account cascade every other user-scoped table here carries. The primary
|
||||
-- key already leads with `user_id`, so the per-account sweep the job and the
|
||||
-- wipe both run needs no second index.
|
||||
ALTER TABLE "mood_predictions"
|
||||
ADD CONSTRAINT "mood_predictions_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Research Mode is gone: the estimated GLP-1 drug-level curve is part of the
|
||||
-- medication page for everyone, so the opt-in flag and the two acknowledgment
|
||||
-- stamps it kept have no reader and no writer left. The endpoint, the
|
||||
-- acknowledgment dialog and the Settings switch went with them.
|
||||
ALTER TABLE "users"
|
||||
DROP COLUMN IF EXISTS "research_mode_enabled",
|
||||
DROP COLUMN IF EXISTS "research_mode_acknowledged_at",
|
||||
DROP COLUMN IF EXISTS "research_mode_acknowledged_version";
|
||||
+65
-25
@@ -427,9 +427,8 @@ model User {
|
||||
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown
|
||||
// once at the start of onboarding: HealthLog is a private tracking tool,
|
||||
// not a clinical assessment or diagnosis; if worried, talk to a doctor.
|
||||
// Mirrors the `researchModeAcknowledged*` pair — the version pins exactly
|
||||
// which copy the user read, so a future material wording change can ask
|
||||
// again. Null = never acknowledged (legacy accounts read as never-asked;
|
||||
// The version pins exactly which copy the user read, so a future material
|
||||
// wording change can ask again. Null = never acknowledged (legacy accounts read as never-asked;
|
||||
// the prompt only gates fresh onboarding, so they are not re-walled). The
|
||||
// acknowledged legal text stays reachable on the public privacy page.
|
||||
disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at")
|
||||
@@ -496,28 +495,6 @@ model User {
|
||||
// work.
|
||||
sourcePriorityJson Json? @map("source_priority_json")
|
||||
|
||||
// v1.4.25 W19c — Research Mode acknowledgment for the opt-in GLP-1
|
||||
// PK research-view chart. The chart is hidden behind a per-user
|
||||
// flag that is set to `true` only after the user has dismissed an
|
||||
// undismissable MDR-disclaimer dialog. The `researchModeAcknowledged*`
|
||||
// pair records exactly *which version* of the disclaimer copy the
|
||||
// user has read; when the disclaimer copy changes (e.g. EMA guidance
|
||||
// update, new drug added), the Settings surface re-prompts the user
|
||||
// and refuses to surface the chart until the new version is
|
||||
// acknowledged. Keeping the version on the user row (instead of an
|
||||
// append-only audit table) is intentional — the Coach refusal layer
|
||||
// and the chart-render guard both need a cheap synchronous read.
|
||||
//
|
||||
// `researchModeEnabled = false` is the safe default — the chart
|
||||
// never renders without an explicit opt-in. The DELETE branch of
|
||||
// the API endpoint sets all three columns back to NULL/false so a
|
||||
// user who turns Research Mode off must re-acknowledge the
|
||||
// disclaimer if they ever turn it back on, even if the version
|
||||
// hasn't changed in the interim.
|
||||
researchModeEnabled Boolean @default(false) @map("research_mode_enabled")
|
||||
researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at")
|
||||
researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version")
|
||||
|
||||
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known
|
||||
// synchronization checkpoint per user. iOS reads it via
|
||||
// `GET /api/sync/state` to decide whether to drain its local
|
||||
@@ -774,6 +751,7 @@ model User {
|
||||
googleHealthOAuthStates GoogleHealthOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
moodContexts MoodContext[]
|
||||
moodPredictions MoodPrediction[]
|
||||
mentalHealthAssessments MentalHealthAssessment[]
|
||||
moodTagsCustom MoodTag[] @relation("UserMoodTags")
|
||||
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
|
||||
@@ -4270,6 +4248,68 @@ model MoodContext {
|
||||
@@map("mood_contexts")
|
||||
}
|
||||
|
||||
/// What the account's own past days say a day like this one usually looks
|
||||
/// like — one row per local day, written by a job and never by a person.
|
||||
///
|
||||
/// Four things about this table that a later reader would otherwise have to
|
||||
/// infer, and would infer wrongly:
|
||||
///
|
||||
/// 1. **Written by the `mood-prognosis-refresh` job only. Never by a route,
|
||||
/// and the user cannot overwrite it.** The self-assessment on `MoodEntry`
|
||||
/// and the value here are two separate readings of the same day and they
|
||||
/// stay separate: the deviation between them is the entire finding, and a
|
||||
/// surface that let somebody correct the prognosis into agreement would
|
||||
/// destroy it. Two tables make that structural rather than a convention,
|
||||
/// and `src/__tests__/mood-prediction-write-surface-guard.test.ts` holds
|
||||
/// the writer set to one file.
|
||||
/// 2. **`features` holds the evidence, not a hint.** It is the JSON list of
|
||||
/// the standardised contributions the fit actually used for that day, so
|
||||
/// the "what the model weighted" line is generated from what happened
|
||||
/// rather than recomputed at render time against a model that may since
|
||||
/// have been re-fitted.
|
||||
/// 3. **Deliberately not in the disaster-recovery backup.** It is a derived
|
||||
/// artefact, recomputable in full from `MoodEntry` + `MoodContext` and the
|
||||
/// linked modules, and the nightly job rebuilds it after a restore. The
|
||||
/// verdict is written down in `DERIVED_MODELS`
|
||||
/// (`src/lib/export/backup-plan.ts`), where the completeness guard reads
|
||||
/// it, so this is a decision rather than an omission. It IS in the wipe
|
||||
/// plan: a derived row is still a statement about somebody's days.
|
||||
/// 4. **`n` and the band are part of the value.** A consumer that renders
|
||||
/// `predicted` without `ciLow` / `ciHigh` / `n` is a defect: a forecast
|
||||
/// built from thirty days and one built from three hundred look identical
|
||||
/// on screen and mean very different things.
|
||||
model MoodPrediction {
|
||||
userId String @map("user_id")
|
||||
/// YYYY-MM-DD in the account's own zone — the same day key and the same
|
||||
/// anchoring `MoodEntry.date` carries, so the two readings of a day join
|
||||
/// without a second timezone derivation.
|
||||
date String
|
||||
|
||||
/// The expected pleasantness (A1) for that day, 0-10, on the account's own
|
||||
/// past pattern. A counterfactual, never a measurement.
|
||||
predicted Float
|
||||
/// The band around it, from the rolling-origin validation residuals. Null
|
||||
/// only when the validation produced no residuals at all, which the write
|
||||
/// path treats as "no forecast" rather than as a bare point estimate.
|
||||
ciLow Float? @map("ci_low")
|
||||
ciHigh Float? @map("ci_high")
|
||||
/// Complete days the fit was built from. Shown wherever `predicted` is.
|
||||
n Int
|
||||
|
||||
/// Bumped whenever the feature set or the fit changes, so a re-fit is
|
||||
/// legible in the stored rows instead of silently changing their meaning.
|
||||
modelVersion String @map("model_version")
|
||||
/// JSON array of `{ feature, contribution }` — the standardised coefficient
|
||||
/// times that day's standardised value, per feature.
|
||||
features String
|
||||
computedAt DateTime @map("computed_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, date])
|
||||
@@map("mood_predictions")
|
||||
}
|
||||
|
||||
// ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ────────────────────────
|
||||
//
|
||||
// Structured, icon-bearing tag catalog modelled on the standalone mood
|
||||
|
||||
@@ -325,7 +325,6 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat
|
||||
onboarding_completed_at, onboarding_step, onboarding_tour_completed,
|
||||
healthkit_config_json, healthkit_last_synced_at,
|
||||
display_name, last_report_practice_name, coach_prefs_json, source_priority_json,
|
||||
research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version,
|
||||
last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs,
|
||||
disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at,
|
||||
full_name, insurer_name, insurance_number_encrypted, insurer_ik_number"
|
||||
@@ -366,7 +365,6 @@ readonly USERS_SELECT="id, username, email, password_hash,
|
||||
onboarding_completed_at, onboarding_step, onboarding_tour_completed,
|
||||
healthkit_config_json, healthkit_last_synced_at,
|
||||
display_name, last_report_practice_name, coach_prefs_json, source_priority_json,
|
||||
research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version,
|
||||
last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs,
|
||||
disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at,
|
||||
full_name,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Copy-matches-behaviour guard.
|
||||
*
|
||||
* Three classes of copy that drifted away from the code they describe, each
|
||||
* pinned here so the pair fails loudly if either half moves again:
|
||||
* Four classes of copy that must not drift away from the code they describe,
|
||||
* each pinned here so the pair fails loudly if either half moves. The first
|
||||
* three are repairs — copy that had already drifted. The fourth arrives with
|
||||
* the feature it governs, because a wording rule that depends on review is a
|
||||
* wording rule that survives until the fifth locale.
|
||||
*
|
||||
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
|
||||
* consequence. Four dialogs promised permanence on surfaces that render
|
||||
@@ -224,3 +227,150 @@ describe("the glucose reference band control is translated everywhere", () => {
|
||||
expect(copy).toContain("never inferred");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 4. THE MOOD PROGNOSIS. The forecast is a comparison with somebody's own past
|
||||
* days and it may never be phrased as a cause. "A value around 5.4 would
|
||||
* have been expected" is what the arithmetic supports; "overtime lowered
|
||||
* your mood" is not, and the difference is not a matter of taste — the model
|
||||
* is a correlation over a dozen columns and a causal sentence would be a
|
||||
* claim it cannot make about somebody's health.
|
||||
*
|
||||
* The second half is the count. A forecast built from thirty days and one
|
||||
* built from three hundred render identically, so every statement that
|
||||
* quotes a value has to quote what it rests on. Both halves are a test here
|
||||
* rather than a review habit, because a review habit does not survive the
|
||||
* fifth locale.
|
||||
*/
|
||||
describe("the mood prognosis never states a cause", () => {
|
||||
/**
|
||||
* Causal constructions, per locale. Multi-word where a single word would
|
||||
* over-match — Italian `causa` is a noun in ordinary use and only `a causa
|
||||
* di` is the construction being banned.
|
||||
*/
|
||||
const CAUSAL_PHRASES: Record<string, readonly string[]> = {
|
||||
de: [
|
||||
"weil",
|
||||
"verursacht",
|
||||
"führt zu",
|
||||
"sorgt für",
|
||||
"wegen",
|
||||
"verbessert deine",
|
||||
"verschlechtert deine",
|
||||
"senkt deine",
|
||||
"hebt deine",
|
||||
],
|
||||
en: [
|
||||
"because",
|
||||
"causes",
|
||||
"caused",
|
||||
"leads to",
|
||||
"due to",
|
||||
"makes you",
|
||||
"lowers your",
|
||||
"raises your",
|
||||
"improves your",
|
||||
"worsens your",
|
||||
],
|
||||
es: [
|
||||
"porque",
|
||||
"provoca",
|
||||
"debido a",
|
||||
"a causa de",
|
||||
"mejora tu",
|
||||
"empeora tu",
|
||||
],
|
||||
fr: [
|
||||
"parce que",
|
||||
"provoque",
|
||||
"en raison de",
|
||||
"à cause de",
|
||||
"améliore ton",
|
||||
"dégrade ton",
|
||||
],
|
||||
it: [
|
||||
"perché",
|
||||
"provoca",
|
||||
"a causa di",
|
||||
"migliora il tuo",
|
||||
"peggiora il tuo",
|
||||
],
|
||||
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
|
||||
};
|
||||
|
||||
/** Every leaf string under `insights.mood.prognosis`, flattened. */
|
||||
function prognosisStrings(locale: string): Array<[string, string]> {
|
||||
const root = (
|
||||
bundle(locale).insights as Record<string, Record<string, unknown>>
|
||||
).mood.prognosis as Record<string, unknown>;
|
||||
expect(
|
||||
root,
|
||||
`${locale} carries no insights.mood.prognosis block`,
|
||||
).toBeTypeOf("object");
|
||||
return Object.entries(root).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
);
|
||||
}
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} carries the block, and every string in it`, () => {
|
||||
const strings = prognosisStrings(locale);
|
||||
// The positive control. Every assertion below passes on an empty list,
|
||||
// so the count is the evidence and the empty offender set is only the
|
||||
// verdict.
|
||||
expect(strings.length).toBeGreaterThanOrEqual(17);
|
||||
for (const [key, value] of strings) {
|
||||
expect(value.length, `${locale}/${key} is empty`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${locale} names no cause`, () => {
|
||||
const offenders = prognosisStrings(locale)
|
||||
.filter(([, value]) =>
|
||||
CAUSAL_PHRASES[locale].some((phrase) =>
|
||||
value.toLowerCase().includes(phrase),
|
||||
),
|
||||
)
|
||||
.map(([key]) => key);
|
||||
expect(
|
||||
offenders,
|
||||
`${locale} prognosis copy states a cause; the forecast is a comparison with the account's own past days and cannot support one`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Statements that quote a value must quote what it rests on. The list is
|
||||
* explicit rather than derived, so adding a statement key forces a decision
|
||||
* here instead of inheriting silence.
|
||||
*/
|
||||
const COUNTED_STATEMENTS: Record<string, readonly string[]> = {
|
||||
statement: ["{value}", "{n}"],
|
||||
learning: ["{entries}", "{threshold}"],
|
||||
noPattern: ["{entries}"],
|
||||
band: ["{low}", "{high}"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} keeps the count beside the claim`, () => {
|
||||
const strings = new Map(prognosisStrings(locale));
|
||||
for (const [key, placeholders] of Object.entries(COUNTED_STATEMENTS)) {
|
||||
const copy = strings.get(key);
|
||||
expect(copy, `${locale}/${key} is missing`).toBeTypeOf("string");
|
||||
for (const placeholder of placeholders) {
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} quotes a value without ${placeholder}; a forecast without the days behind it cannot be weighed by the reader`,
|
||||
).toContain(placeholder);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the forecast is phrased as a counterfactual, not as a measurement (EN + DE)", () => {
|
||||
const en = resolve(bundle("en"), "insights.mood.prognosis.statement");
|
||||
expect(en.toLowerCase()).toContain("would have been expected");
|
||||
const de = resolve(bundle("de"), "insights.mood.prognosis.statement");
|
||||
expect(de.toLowerCase()).toContain("zu erwarten gewesen");
|
||||
});
|
||||
});
|
||||
@@ -111,13 +111,6 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
"every health record on the account, or the whole account including passkeys and audit history",
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "research-mode switch, off position",
|
||||
reason:
|
||||
"Turning research mode off drops the stored acknowledgement receipt, which is regenerated by turning it back on and re-acknowledging. Nothing a person authored or measured is involved, so a dialog on a labelled switch would gate a preference rather than a loss.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/settings/security-sessions-card.tsx",
|
||||
@@ -388,6 +381,26 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Visits and the address book ─────────────────────────────────────────
|
||||
{
|
||||
file: "hooks/use-encounters.ts",
|
||||
destroys:
|
||||
"a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it",
|
||||
triggers: ["components/encounters/encounter-sheet.tsx"],
|
||||
// The route soft-deletes and a restore route exists, but nothing in the
|
||||
// product reaches it yet, so from a person's side the visit is gone. This
|
||||
// becomes "restorable" the day a surface offers the undo, not before.
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "hooks/use-practitioners.ts",
|
||||
destroys: "one address-book entry",
|
||||
triggers: ["components/practitioners/practitioner-list.tsx"],
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
|
||||
// ── Cycle ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
file: "components/cycle/use-cycle.ts",
|
||||
|
||||
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(91);
|
||||
expect(ALL_LEAF_IDS.length).toBe(92);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("places every leaf in exactly one group", () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* `MoodPrediction` has exactly one writer, and it is not a request.
|
||||
*
|
||||
* The product rule underneath this guard: a day carries two readings — the
|
||||
* one the person gave it and the one their own past days imply — and they are
|
||||
* never merged. The self-assessment leads and is stored exactly as it was
|
||||
* given; the prognosis is computed, carries a band and a case count, and the
|
||||
* user cannot overwrite it. A route that wrote `moodPrediction` would make the
|
||||
* second value editable, and an editable prognosis is not a prognosis: the
|
||||
* deviation between the two, which is the only thing either number is for,
|
||||
* would quietly become a number somebody could tune to zero.
|
||||
*
|
||||
* The second failure the single writer prevents is the one the cycle module
|
||||
* already paid for: a forecast written as a side effect of a page view makes
|
||||
* the output a function of browsing rather than of data
|
||||
* (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`).
|
||||
*
|
||||
* **Every matcher here asserts a non-zero match count.** Legs (a) and (b)
|
||||
* expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look
|
||||
* identical from the outside — which is precisely how the bearer-scope guard
|
||||
* stayed green for months while matching nothing at all. Each leg therefore
|
||||
* pins a separate positive: the number of files SCANNED, and the write calls
|
||||
* the legitimate writer really does make. `describe("the matcher sees what it
|
||||
* looks for")` proves the regex against synthetic sources, including the
|
||||
* broken-across-lines form that defeated an earlier guard in this repository.
|
||||
*
|
||||
* **What this guard structurally cannot see, stated like the session-surface
|
||||
* guard states its own limit.** The matcher keys on a literal `moodPrediction`
|
||||
* delegate access. The "Delete All Data" wipe DOES delete this table — the
|
||||
* model is in `WIPE_MODELS` (`src/lib/data-wipe/wipe-plan.ts`) — but it does so
|
||||
* through a delegate resolved dynamically from the model name (`client[key]
|
||||
* .deleteMany`), so no `moodPrediction.deleteMany(` literal exists for the
|
||||
* matcher to catch, and that deletion is correctly NOT counted as a writer.
|
||||
* The wipe path's own completeness is proven elsewhere, by
|
||||
* `data-wipe-completeness.test.ts` enumerating the schema; this guard is only
|
||||
* the one about request-time writers, and it cannot be the gate on a dynamic
|
||||
* delegate. A future writer that reached the table through a computed property
|
||||
* would slip this matcher for the same reason.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* A write against the `moodPrediction` delegate, on any receiver.
|
||||
*
|
||||
* Whitespace-tolerant in every joint: Prettier breaks a long call across
|
||||
* lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line
|
||||
* is a matcher that stops matching the day the line gets long enough. The
|
||||
* receiver is left open (`prisma`, `tx`, `db`, the worker client) because
|
||||
* pinning one name would let a rename walk straight past.
|
||||
*/
|
||||
const WRITE_CALL =
|
||||
/\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g;
|
||||
|
||||
/** The one file allowed to write, relative to `src/`. */
|
||||
const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "generated" || entry.name === "node_modules") continue;
|
||||
out.push(...sourceFiles(full));
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeCallsIn(source: string): string[] {
|
||||
return [...source.matchAll(WRITE_CALL)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
const ALL_FILES = sourceFiles(SRC);
|
||||
const PRODUCTION_FILES = ALL_FILES.filter(
|
||||
(f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"),
|
||||
);
|
||||
|
||||
describe("MoodPrediction is written by one file", () => {
|
||||
it("scans a plausible number of source files", () => {
|
||||
// The positive control for legs (a) and (b) below, both of which pass on
|
||||
// an empty result. If this collapses, they are asserting nothing.
|
||||
expect(ALL_FILES.length).toBeGreaterThan(1000);
|
||||
expect(PRODUCTION_FILES.length).toBeGreaterThan(800);
|
||||
});
|
||||
|
||||
it("(a) no route, page or component writes it", () => {
|
||||
const offenders = PRODUCTION_FILES.filter(
|
||||
(f) =>
|
||||
f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")),
|
||||
).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0);
|
||||
|
||||
expect(
|
||||
offenders.map((f) => f.slice(SRC.length + 1)),
|
||||
"a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("(b) the writer set is exactly the refresh job", () => {
|
||||
const writers = PRODUCTION_FILES.filter(
|
||||
(f) => writeCallsIn(readFileSync(f, "utf8")).length > 0,
|
||||
).map((f) => f.slice(SRC.length + 1));
|
||||
|
||||
expect(writers.sort()).toEqual([SOLE_WRITER]);
|
||||
});
|
||||
|
||||
it("(c) that writer really does write — the matcher is not matching nothing", () => {
|
||||
expect(
|
||||
existsSync(join(SRC, SOLE_WRITER)),
|
||||
`${SOLE_WRITER} does not exist — the table has a guard and no writer`,
|
||||
).toBe(true);
|
||||
const source = readFileSync(join(SRC, SOLE_WRITER), "utf8");
|
||||
const calls = writeCallsIn(source);
|
||||
expect(
|
||||
calls.length,
|
||||
"the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth",
|
||||
).toBeGreaterThan(0);
|
||||
// It replaces rather than accumulates: a pass writes the current window
|
||||
// and clears whatever fell out of it.
|
||||
expect(calls).toContain("deleteMany");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
it("matches a call Prettier broke across lines", () => {
|
||||
const wrapped = [
|
||||
"await prisma.moodPrediction",
|
||||
" .upsert(",
|
||||
" { where: { userId_date: { userId, date } } },",
|
||||
" );",
|
||||
].join("\n");
|
||||
expect(writeCallsIn(wrapped)).toEqual(["upsert"]);
|
||||
});
|
||||
|
||||
it("matches through an aliased client", () => {
|
||||
expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([
|
||||
"deleteMany",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not match a read", () => {
|
||||
expect(
|
||||
writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match a different model", () => {
|
||||
expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -44,12 +44,44 @@ describe("shared-record navigation", () => {
|
||||
it("keeps profile facts outside Settings and makes profile the scoped doorway", () => {
|
||||
const navigation = resolveSharedRecordNavigation(["profile"]);
|
||||
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile"]);
|
||||
expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([
|
||||
"/profile",
|
||||
"/checkups",
|
||||
]);
|
||||
expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]);
|
||||
expect(navigation.allowsPath("/profile")).toBe(true);
|
||||
expect(navigation.allowsPath("/settings/anamnesis")).toBe(false);
|
||||
});
|
||||
|
||||
it("presents the checkups page under both domains whose content it shows", () => {
|
||||
// The page holds two things: the preventive-care list, which is a
|
||||
// `measurements` read, and the visits section, which is `profile`. A grant
|
||||
// naming either one must be able to reach it; a grant naming neither must
|
||||
// not. Before the visits section the `measurements` omission was invisible,
|
||||
// because a whole-record grant admits every path regardless.
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"),
|
||||
).toBe(true);
|
||||
expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"),
|
||||
).toBe(false);
|
||||
|
||||
// Listed twice, offered once: a grant holding both domains must not put
|
||||
// two identical entries in the navigation.
|
||||
expect(
|
||||
resolveSharedRecordNavigation([
|
||||
"measurements",
|
||||
"profile",
|
||||
]).destinationHrefs.filter((href) => href === "/checkups"),
|
||||
).toEqual(["/checkups"]);
|
||||
});
|
||||
|
||||
it("uses the active record scope instead of the actor's module flags", () => {
|
||||
const actorModules = {
|
||||
medications: false,
|
||||
|
||||
@@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "record",
|
||||
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.",
|
||||
},
|
||||
"app/api/mood/prognosis/route.ts": {
|
||||
domain: "record",
|
||||
why: "The day's two readings — the record's own rating and what its past days imply — read-only. It is NOT a mind read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and the payload names the features that carried weight, so a reader sees that sleep or steps or resting heart rate mattered on a given day. Declared `mind` it would hand a mood-only delegate a derived view of sections the owner declined to share. Admitted on the whole-record grant only, like the linked-day read beside it; a scoped grant is refused rather than served a filtered contribution list, which would falsely read as \"the model weighted only the mood fields\".",
|
||||
},
|
||||
"app/api/mood-entries/[id]/route.ts": {
|
||||
domain: "mind",
|
||||
why: "One mood entry of the record, fetch-then-guard against the resolved user.",
|
||||
@@ -935,6 +939,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.",
|
||||
},
|
||||
"app/api/encounters/suggest/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.",
|
||||
},
|
||||
"app/api/vaccinations/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's immunization history. Health background in the same sense as the allergies and the visits already under this domain: it names no credential, no integration and no notification channel. It carries the same honest cost as those two, which is that a delegate granted `profile` to read an allergy list also sees every dose the person has had; the consent copy names the immunization history for exactly this reason. The create arm is a delegable write.",
|
||||
@@ -1884,13 +1892,17 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* managed-record configuration adds one more: 196 → 197. The bounded
|
||||
* read-only profile summary makes the next explicit admission: 197 → 198.
|
||||
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
|
||||
* The mood day's linked figures, read-only, add one: 200 → 201.
|
||||
* The mood day's linked figures, read-only, add one: 200 -> 201. The visit
|
||||
* record and its address book bring twelve: 201 -> 213. The mood prognosis,
|
||||
* read-only and admitted on the whole record for the same reason as the linked
|
||||
* figures, adds one: 213 -> 214. The visit-suggestion read, which serves the
|
||||
* three incidental-linking moments from the same rule, adds one: 214 -> 215.
|
||||
*
|
||||
* v1.38.0 — the immunization log adds six: two reads and one restore on the
|
||||
* v1.38.0 -- the immunization log adds six: two reads and one restore on the
|
||||
* record list, one create on the write literal, and the edit/delete pair plus
|
||||
* the restore on the manage literal. 213 → 219.
|
||||
* the restore on the manage literal. 215 -> 221.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 219;
|
||||
const FROZEN_ENTRY_COUNT = 221;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -230,9 +230,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/medications/intake-history-list-v2.tsx": {
|
||||
"text-success": 2,
|
||||
},
|
||||
"src/components/medications/research-mode-acknowledgment-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/scheduling/schedule-history-timeline.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
@@ -286,7 +283,7 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/account-section/avatar-section.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/account-section/index.tsx": { "text-success": 2 },
|
||||
"src/components/settings/account-section/index.tsx": { "text-success": 1 },
|
||||
"src/components/settings/advanced-section.tsx": { "text-success": 2 },
|
||||
"src/components/settings/ai/ai-insights-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/anthropic-provider-form.tsx": {
|
||||
@@ -341,6 +338,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
|
||||
// The one that used to sit inside `account-section/index.tsx` — the card
|
||||
// moved to Settings → Security, the line moved with it.
|
||||
"src/components/settings/password-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/security-sessions-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
|
||||
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
|
||||
it("is idempotent when the column already holds the requested value", async () => {
|
||||
// Toggling a column from true→true (or false→false) still writes
|
||||
// through so the audit row exists. Matches the research-mode
|
||||
// through so the audit row exists. Matches the doctor-report-prefs
|
||||
// route's "always write" posture.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE, GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPost(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mkDelete(): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the disabled default for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
expect(env.data.currentDisclaimerVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the enabled state with the persisted version", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const ackAt = new Date("2026-05-14T10:00:00Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: ackAt,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string;
|
||||
acknowledgedVersion: string;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables Research Mode and writes the audit log on a valid ack", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stale disclaimer version with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: "1970-01-01.0" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a body without acknowledged=true with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Research Mode and clears the acknowledgment", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent when Research Mode is already off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
// The route writes the canonical off-state even when the
|
||||
// column is already off — guarantees audit trail symmetry.
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c — Research Mode acknowledgment endpoint.
|
||||
*
|
||||
* GET /api/auth/me/research-mode — returns the current flag,
|
||||
* the timestamp + version of
|
||||
* the last acknowledgment, and
|
||||
* the live disclaimer version
|
||||
* so the Settings UI can
|
||||
* compare and re-prompt.
|
||||
* POST /api/auth/me/research-mode — body { acknowledged: true,
|
||||
* version }. Flips the flag
|
||||
* on. Rejects 400 if `version`
|
||||
* is not the live version.
|
||||
* DELETE /api/auth/me/research-mode — disables Research Mode and
|
||||
* clears the acknowledgment.
|
||||
* Idempotent.
|
||||
*
|
||||
* Per-user POST rate-limit is intentionally low (5/min) — this is a
|
||||
* one-tap acknowledgment dialog, not a hot path. Anything above that
|
||||
* traffic looks like a malformed client retry-loop or an exploit.
|
||||
*
|
||||
* Prompt-injection surface — the API accepts the disclaimer
|
||||
* `version` string and nothing else from the client. The version
|
||||
* value never reaches the LLM; it is matched against the
|
||||
* server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then
|
||||
* persisted as the stamp on the user row. No free-text body.
|
||||
*
|
||||
* Audit-log entries fire on every state-changing call (POST, DELETE)
|
||||
* with the previous + next state, mirroring the doctor-report-prefs
|
||||
* route's reconcile pattern (W10 security M-3).
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const POST_RATE_LIMIT = 5;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
type ResearchModeResponse = {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
|
||||
function shape(row: {
|
||||
researchModeEnabled: boolean;
|
||||
researchModeAcknowledgedAt: Date | null;
|
||||
researchModeAcknowledgedVersion: string | null;
|
||||
}): ResearchModeResponse {
|
||||
return {
|
||||
enabled: row.researchModeEnabled,
|
||||
acknowledgedAt: row.researchModeAcknowledgedAt
|
||||
? row.researchModeAcknowledgedAt.toISOString()
|
||||
: null,
|
||||
acknowledgedVersion: row.researchModeAcknowledgedVersion,
|
||||
currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.research-mode.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
// `requireAuth` already proved the user exists; defensive default
|
||||
// here only matters for the (impossible-in-practice) race where
|
||||
// the user is deleted mid-request.
|
||||
return apiSuccess(
|
||||
shape(
|
||||
row ?? {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user rate-limit. 5/min is generous for a once-per-update
|
||||
// acknowledgment dialog; anything more looks like a retry loop.
|
||||
const rl = await checkRateLimit(
|
||||
`research-mode:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "research-mode.body.invalid_json");
|
||||
}
|
||||
|
||||
// Hand-rolled validation — the body is tiny (two fields), and
|
||||
// staying off Zod keeps the prompt-injection surface visibly
|
||||
// simple in a code review.
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
(body as { acknowledged?: unknown }).acknowledged !== true ||
|
||||
typeof (body as { version?: unknown }).version !== "string"
|
||||
) {
|
||||
annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } });
|
||||
throw new HttpError(422, "research-mode.body.invalid_shape");
|
||||
}
|
||||
|
||||
const submittedVersion = (body as { version: string }).version;
|
||||
|
||||
// Stale-version guard — the dialog must show the *live* copy at
|
||||
// the moment of acknowledgment. A stale version means the user
|
||||
// is acknowledging older wording; we refuse and force the
|
||||
// Settings UI to re-render with the new disclaimer.
|
||||
if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) {
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post.stale_version" },
|
||||
meta: {
|
||||
submitted: submittedVersion,
|
||||
current: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
},
|
||||
});
|
||||
throw new HttpError(400, "research-mode.version.stale");
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
const next = {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.enable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt:
|
||||
next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post" },
|
||||
meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION },
|
||||
});
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Idempotent — if Research Mode is already off, we still write
|
||||
// the canonical "off" state so the audit row exists.
|
||||
const next = {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.disable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { previous: previous ?? null, next },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.me.research-mode.delete" } });
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
@@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
encounterDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({
|
||||
illnessEpisode: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounter: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -129,6 +137,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -15,7 +15,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadConditionLinks } from "@/lib/documents/links";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -83,9 +86,20 @@ export const POST = apiHandler(
|
||||
if (row.deletedAt === null) {
|
||||
// Already live — restoring twice is a no-op, not an error (the undo
|
||||
// toast may fire after a concurrent restore).
|
||||
const links = await loadConditionLinks(user.id, [row.id]);
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [row.id]),
|
||||
loadDocumentEncounterLinks(user.id, [row.id]),
|
||||
]);
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []),
|
||||
serialiseDocumentDetail(
|
||||
row,
|
||||
row.facts,
|
||||
links.get(row.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(row.id) ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +145,10 @@ export const POST = apiHandler(
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
const links = await loadConditionLinks(user.id, [restored.id]);
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [restored.id]),
|
||||
loadDocumentEncounterLinks(user.id, [restored.id]),
|
||||
]);
|
||||
|
||||
await auditLog("documents.inbound.restore", {
|
||||
userId: user.id,
|
||||
@@ -149,6 +166,10 @@ export const POST = apiHandler(
|
||||
restored,
|
||||
restored.facts,
|
||||
links.get(restored.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(restored.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,8 +20,11 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
narrowOwnedEncounterIds,
|
||||
narrowOwnedEpisodeIds,
|
||||
replaceConditionLinks,
|
||||
replaceEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -92,8 +95,9 @@ export const GET = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
const [links, contentIndex, thumbnail] = await Promise.all([
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
@@ -117,6 +121,7 @@ export const GET = apiHandler(
|
||||
contentIndex !== null,
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -169,6 +174,21 @@ export const PATCH = apiHandler(
|
||||
nextEpisodeIds = narrowed;
|
||||
}
|
||||
|
||||
// Replace-set visit links, same semantics and same refusal.
|
||||
let nextEncounterIds: string[] | undefined;
|
||||
if (parsed.data.encounterIds !== undefined) {
|
||||
const narrowed = await narrowOwnedEncounterIds(
|
||||
user.id,
|
||||
parsed.data.encounterIds,
|
||||
);
|
||||
if (narrowed === null) {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
}
|
||||
nextEncounterIds = narrowed;
|
||||
}
|
||||
|
||||
// No mass assignment — each editable column is set explicitly only when
|
||||
// the client sent it. `userId` is never a body field.
|
||||
const data: Prisma.InboundDocumentUpdateInput = {};
|
||||
@@ -189,14 +209,23 @@ export const PATCH = apiHandler(
|
||||
if (nextEpisodeIds !== undefined) {
|
||||
await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds);
|
||||
}
|
||||
if (nextEncounterIds !== undefined) {
|
||||
await replaceEncounterLinks(
|
||||
prisma,
|
||||
user.id,
|
||||
existing.id,
|
||||
nextEncounterIds,
|
||||
);
|
||||
}
|
||||
|
||||
const document = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: existing.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
const [links, contentIndex, thumbnail] = await Promise.all([
|
||||
const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
prisma.documentContentIndex.findUnique({
|
||||
where: { documentId: document.id },
|
||||
select: { source: true },
|
||||
@@ -210,6 +239,7 @@ export const PATCH = apiHandler(
|
||||
const touched = [
|
||||
...Object.keys(data),
|
||||
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
|
||||
...(nextEncounterIds !== undefined ? ["encounterIds"] : []),
|
||||
];
|
||||
|
||||
await auditLog("documents.inbound.update", {
|
||||
@@ -234,6 +264,7 @@ export const PATCH = apiHandler(
|
||||
contentIndex !== null,
|
||||
toContentIndexSource(contentIndex?.source),
|
||||
thumbnail !== null,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ const {
|
||||
txCreateMany,
|
||||
txDocumentFindMany,
|
||||
txEpisodeFindMany,
|
||||
txEncounterFindMany,
|
||||
txEncounterLinkCreateMany,
|
||||
} = vi.hoisted(() => ({
|
||||
txQueryRaw: vi.fn(),
|
||||
txCreate: vi.fn(),
|
||||
@@ -28,6 +30,8 @@ const {
|
||||
// writes, so the transaction double has to answer for them.
|
||||
txDocumentFindMany: vi.fn(),
|
||||
txEpisodeFindMany: vi.fn(),
|
||||
txEncounterFindMany: vi.fn(),
|
||||
txEncounterLinkCreateMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
@@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => {
|
||||
$queryRaw: txQueryRaw,
|
||||
inboundDocument: { create: txCreate, findMany: txDocumentFindMany },
|
||||
illnessEpisode: { findMany: txEpisodeFindMany },
|
||||
encounter: { findMany: txEncounterFindMany },
|
||||
documentConditionLink: { createMany: txCreateMany },
|
||||
encounterDocumentLink: { createMany: txEncounterLinkCreateMany },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
@@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => {
|
||||
documentConditionLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounterDocumentLink: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
documentContentIndex: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
@@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => {
|
||||
illnessEpisode: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
encounter: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
@@ -183,6 +195,12 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
txEncounterFindMany.mockResolvedValue([]);
|
||||
txEncounterLinkCreateMany.mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Document vault: bulk actions over up to 100 owner-scoped documents.
|
||||
*
|
||||
* `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions:
|
||||
* `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition
|
||||
* links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A
|
||||
* `{ ids[], action, kind?, episodeId?, encounterId? }` → a per-id result
|
||||
* array. Actions: `setKind` (recategorise), `linkEpisode` / `unlinkEpisode`
|
||||
* (condition links), `linkEncounter` / `unlinkEncounter` (visit links, the
|
||||
* same table the visit's own sheet writes from the other end),
|
||||
* `delete` (tombstone, undo-able), `restore` (clear tombstone). A
|
||||
* partial failure never aborts the batch — each id reports `ok` or a short
|
||||
* machine reason. Every id is narrowed to the caller; a foreign id reads as
|
||||
* "notFound", exactly like the single-document routes.
|
||||
@@ -68,7 +70,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { ids, action, kind, episodeId } = parsed.data;
|
||||
const { ids, action, kind, episodeId, encounterId } = parsed.data;
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
|
||||
// Episode actions: the target episode must be a LIVE episode of the caller.
|
||||
@@ -84,6 +86,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Visit actions: the target visit must be a LIVE visit of the caller. Same
|
||||
// shape as the episode gate above — the endpoint is checked once, before the
|
||||
// batch, so a foreign id costs one refusal rather than a hundred.
|
||||
if (action === "linkEncounter" || action === "unlinkEncounter") {
|
||||
const encounter = await prisma.encounter.findFirst({
|
||||
where: { id: encounterId!, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!encounter) {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// One owner-scoped sweep resolves every id's current state; per-id work
|
||||
// then never touches a row the caller does not own.
|
||||
const owned = await prisma.inboundDocument.findMany({
|
||||
@@ -136,6 +153,33 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "linkEncounter": {
|
||||
if (row.deletedAt !== null) {
|
||||
results.push({ id, ok: false, error: "notFound" });
|
||||
continue;
|
||||
}
|
||||
await linkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "document",
|
||||
sourceId: id,
|
||||
targetKind: "encounter",
|
||||
targetIds: [encounterId!],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "unlinkEncounter": {
|
||||
// No tombstone check, matching `unlinkEpisode`: clearing a filing on
|
||||
// a document the account has since deleted must stay possible, or
|
||||
// the filing is unreachable.
|
||||
await unlinkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "document",
|
||||
sourceId: id,
|
||||
targetKind: "encounter",
|
||||
targetIds: [encounterId!],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
if (row.deletedAt !== null) {
|
||||
// Already tombstoned — deleting twice is a no-op success.
|
||||
|
||||
@@ -39,6 +39,8 @@ import { prisma } from "@/lib/db";
|
||||
import { hashQueryTokens } from "@/lib/documents/content-index";
|
||||
import {
|
||||
loadConditionLinks,
|
||||
loadDocumentEncounterLinks,
|
||||
narrowOwnedEncounterIds,
|
||||
narrowOwnedEpisodeIds,
|
||||
} from "@/lib/documents/links";
|
||||
import {
|
||||
@@ -116,8 +118,9 @@ async function duplicateResponse(
|
||||
userId: string,
|
||||
existing: SerialisableDocument,
|
||||
): Promise<NextResponse> {
|
||||
const [links, groups] = await Promise.all([
|
||||
const [links, visitLinks, groups] = await Promise.all([
|
||||
loadConditionLinks(userId, [existing.id]),
|
||||
loadDocumentEncounterLinks(userId, [existing.id]),
|
||||
prisma.extractedFact.groupBy({
|
||||
by: ["status"],
|
||||
where: { userId, documentId: existing.id },
|
||||
@@ -140,6 +143,10 @@ async function duplicateResponse(
|
||||
existing,
|
||||
{ factCount, pendingCount },
|
||||
links.get(existing.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(existing.id) ?? [],
|
||||
),
|
||||
error: null,
|
||||
meta: { duplicate: true },
|
||||
@@ -212,17 +219,21 @@ async function processUpload(
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
// Optional metadata (title / kind / documentDate / episodeIds). The file is
|
||||
// read separately; these are the form fields beside it. `episodeIds` may be
|
||||
// repeated.
|
||||
// Optional metadata (title / kind / documentDate / episodeIds /
|
||||
// encounterIds). The file is read separately; these are the form fields
|
||||
// beside it. Both id lists may be repeated.
|
||||
const rawEpisodeIds = formData
|
||||
.getAll("episodeIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
const rawEncounterIds = formData
|
||||
.getAll("encounterIds")
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
const parsed = documentCreateSchema.safeParse({
|
||||
title: formData.get("title") ?? undefined,
|
||||
kind: formData.get("kind") ?? undefined,
|
||||
documentDate: formData.get("documentDate") ?? undefined,
|
||||
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
|
||||
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document metadata", 422, {
|
||||
@@ -268,6 +279,19 @@ async function processUpload(
|
||||
});
|
||||
}
|
||||
|
||||
// Same for the visit ids the review step offered. A refusal rather than a
|
||||
// silent drop: the person saw a visit named and would otherwise watch the
|
||||
// upload succeed without the link they asked for.
|
||||
const encounterIds = await narrowOwnedEncounterIds(
|
||||
user.id,
|
||||
parsed.data.encounterIds ?? [],
|
||||
);
|
||||
if (encounterIds === null) {
|
||||
return apiError("Visit not found", 404, {
|
||||
errorCode: "documents.inbound.encounterNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
// sha256 of the PLAINTEXT for same-user duplicate detection.
|
||||
const contentSha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
@@ -346,6 +370,15 @@ async function processUpload(
|
||||
targetIds: episodeIds,
|
||||
});
|
||||
}
|
||||
if (encounterIds.length > 0) {
|
||||
await linkTargets(tx, {
|
||||
userId: user.id,
|
||||
sourceKind: "document",
|
||||
sourceId: created.id,
|
||||
targetKind: "encounter",
|
||||
targetIds: encounterIds,
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -382,6 +415,7 @@ async function processUpload(
|
||||
byteSize: document.byteSize,
|
||||
servingClass: detected.servingClass,
|
||||
linked: episodeIds.length,
|
||||
linkedVisits: encounterIds.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -406,12 +440,19 @@ async function processUpload(
|
||||
// because of it. Only fresh inserts reach here (a duplicate returns early).
|
||||
void enqueueDocumentSummary(user.id, document.id);
|
||||
|
||||
const links = await loadConditionLinks(user.id, [document.id]);
|
||||
const [links, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [document.id]),
|
||||
loadDocumentEncounterLinks(user.id, [document.id]),
|
||||
]);
|
||||
return apiSuccess(
|
||||
serialiseDocument(
|
||||
document,
|
||||
{ factCount: 0, pendingCount: 0 },
|
||||
links.get(document.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.get(document.id) ?? [],
|
||||
),
|
||||
201,
|
||||
);
|
||||
@@ -481,8 +522,19 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
errorCode: "documents.inbound.invalidQuery",
|
||||
});
|
||||
}
|
||||
const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } =
|
||||
parsed.data;
|
||||
const {
|
||||
q,
|
||||
kind,
|
||||
episodeId,
|
||||
encounterId,
|
||||
year,
|
||||
from,
|
||||
to,
|
||||
sort,
|
||||
order,
|
||||
cursor,
|
||||
limit,
|
||||
} = parsed.data;
|
||||
|
||||
const where: Prisma.InboundDocumentWhereInput = {
|
||||
userId: user.id,
|
||||
@@ -490,6 +542,10 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
};
|
||||
if (kind && kind.length > 0) where.kind = { in: kind };
|
||||
if (episodeId) where.conditionLinks = { some: { episodeId } };
|
||||
// The visit filter reads the SAME table the visit's own sheet writes, from
|
||||
// the document side. Filtering rather than post-filtering: a page of fifty
|
||||
// documents narrowed in Node would page wrongly.
|
||||
if (encounterId) where.encounterLinks = { some: { encounterId } };
|
||||
if (year !== undefined) {
|
||||
where.documentDate = {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
@@ -576,10 +632,16 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
}
|
||||
|
||||
// Condition links for the page in ONE grouped query (no N+1).
|
||||
const linkMap = await loadConditionLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
);
|
||||
const [linkMap, visitLinkMap] = await Promise.all([
|
||||
loadConditionLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
),
|
||||
loadDocumentEncounterLinks(
|
||||
user.id,
|
||||
page.map((d) => d.id),
|
||||
),
|
||||
]);
|
||||
|
||||
// Which of the page's documents have a content index (drives the searchable
|
||||
// status + the provenance the UI reads to tell an AI-read document from a
|
||||
@@ -610,7 +672,9 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
count: page.length,
|
||||
sort,
|
||||
order,
|
||||
filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year),
|
||||
filtered: Boolean(
|
||||
q || (kind && kind.length > 0) || episodeId || encounterId || year,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -623,6 +687,7 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
indexSources.has(doc.id),
|
||||
toContentIndexSource(indexSources.get(doc.id)),
|
||||
thumbnailIds.has(doc.id),
|
||||
visitLinkMap.get(doc.id) ?? [],
|
||||
),
|
||||
),
|
||||
nextCursor,
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
loadEncounterLinksForMany,
|
||||
closeCheckupForVisit,
|
||||
resolveClosableReminder,
|
||||
resolveOwnedPractitioner,
|
||||
@@ -50,7 +51,7 @@ import {
|
||||
} from "@/lib/export/restore-skips";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = encounterListQuerySchema.safeParse({
|
||||
@@ -58,6 +59,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
to: params.get("to") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
practitionerId: params.get("practitionerId") ?? undefined,
|
||||
episodeId: params.get("episodeId") ?? undefined,
|
||||
limit: params.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
@@ -75,6 +77,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
deletedAt: null,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.practitionerId ? { practitionerId: query.practitionerId } : {}),
|
||||
// Filtered in SQL rather than in Node: the two arms are separately
|
||||
// `take`-bounded, so narrowing after the read would page wrongly.
|
||||
...(query.episodeId
|
||||
? { conditionLinks: { some: { episodeId: query.episodeId } } }
|
||||
: {}),
|
||||
...(query.from || query.to
|
||||
? {
|
||||
occurredAt: {
|
||||
@@ -118,14 +125,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
// The links ride the list. The card counts what a visit produced, and the
|
||||
// edit sheet seeds its pickers from the row it was handed — a list without
|
||||
// them makes the first read zero and the second unfile everything on save.
|
||||
// Three grouped queries for the page, not three per row.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const links = await loadEncounterLinksForMany(
|
||||
prisma,
|
||||
user.id,
|
||||
[...upcoming, ...past].map((row) => row.id),
|
||||
visible,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "encounter.visit.list", entity_type: "encounter" },
|
||||
meta: { upcoming: upcoming.length, past: past.length },
|
||||
});
|
||||
|
||||
const body: EncounterListDTO = {
|
||||
upcoming: upcoming.map((row) => toEncounterDTO(row)),
|
||||
past: past.map((row) => toEncounterDTO(row)),
|
||||
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
|
||||
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
|
||||
};
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* `GET /api/encounters/suggest?anchor=<iso>` — which visit does a record dated
|
||||
* `anchor` most plausibly belong to?
|
||||
*
|
||||
* The three capture moments that ask this question live in the browser: the
|
||||
* document upload review, the lab extraction review and the manual lab form.
|
||||
* The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and
|
||||
* the one-pre-selects / two-offer-a-picker verdict — lives in
|
||||
* `src/lib/encounters/suggest-window.ts` and is answered here so all three read
|
||||
* the same answer. Handing the client a windowed list and letting it decide
|
||||
* would put the rule in two places, and the copy in the browser is the one
|
||||
* nobody re-reads when the rule changes.
|
||||
*
|
||||
* A read of the record's own visits, so it declares the same domain the visit
|
||||
* list does. `userId` is narrowed from auth; the anchor is the only input and
|
||||
* it is a date, not an id.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { encounterSuggestQuerySchema } from "@/lib/validations/encounters";
|
||||
import { suggestEncounterForDate } from "@/lib/encounters/suggest-window";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = encounterSuggestQuerySchema.safeParse({
|
||||
anchor: params.get("anchor") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "encounter.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await suggestEncounterForDate(prisma, {
|
||||
userId: user.id,
|
||||
anchor: new Date(parsed.data.anchor),
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "encounter.visit.suggest", entity_type: "encounter" },
|
||||
meta: {
|
||||
verdict: result.kind,
|
||||
candidates:
|
||||
result.kind === "many"
|
||||
? result.encounters.length
|
||||
: result.kind === "one"
|
||||
? 1
|
||||
: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
encounter: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
userHealthProfile: { findUnique: vi.fn() },
|
||||
@@ -165,6 +166,7 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
linkOcrLabsToVaultDocument,
|
||||
type InsertedLabForLink,
|
||||
} from "@/lib/labs/vault-link";
|
||||
import { linkTargets } from "@/lib/links";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
import {
|
||||
@@ -217,6 +218,23 @@ async function commitOcrRows(request: NextRequest) {
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// The visit the review step offered, if it offered one. ONE LINK PER RESULT
|
||||
// ROW rather than one for the panel: a marker re-run on a different day
|
||||
// belongs to its own visit, which is exactly why the join is m:n and not a
|
||||
// scalar column on the result. Best-effort like the vault cross-link — the
|
||||
// readings are the authoritative write and must not be lost to a link that
|
||||
// could not be made. The link service narrows both ends to this record, so
|
||||
// an id naming nothing writes nothing.
|
||||
if (parsed.data.encounterId && linkable.length > 0) {
|
||||
await linkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "encounter",
|
||||
sourceId: parsed.data.encounterId,
|
||||
targetKind: "labResult",
|
||||
targetIds: linkable.map((row) => row.labResultId),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
await auditLog("labs.ocr.commit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -225,7 +243,11 @@ async function commitOcrRows(request: NextRequest) {
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.ocr.committed" },
|
||||
meta: { inserted: inserted.length, skipped: skipped.length },
|
||||
meta: {
|
||||
inserted: inserted.length,
|
||||
skipped: skipped.length,
|
||||
linkedVisit: parsed.data.encounterId ? 1 : 0,
|
||||
},
|
||||
});
|
||||
|
||||
// A lab panel just landed — resolve any "annual blood panel" reminders now
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type ResolvedBiomarker,
|
||||
serialiseLabResult,
|
||||
} from "@/lib/labs/serialise";
|
||||
import { linkTargets } from "@/lib/links";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -190,6 +191,7 @@ async function postLabResult(request: NextRequest) {
|
||||
takenAt,
|
||||
note,
|
||||
source,
|
||||
encounterId,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead
|
||||
@@ -267,6 +269,21 @@ async function postLabResult(request: NextRequest) {
|
||||
noteEncrypted: note ? encryptNoteToBytes(note) : null,
|
||||
},
|
||||
});
|
||||
// The by-the-way half: file the reading against the visit the form offered,
|
||||
// if it offered one. Best-effort by design — the reading is the write that
|
||||
// matters, and a link that could not be made (a visit deleted between the
|
||||
// suggestion and the save) must not lose it. The link service narrows both
|
||||
// ends to this record, so an id naming nothing simply writes nothing.
|
||||
if (encounterId) {
|
||||
await linkTargets(prisma, {
|
||||
userId: user.id,
|
||||
sourceKind: "encounter",
|
||||
sourceId: encounterId,
|
||||
targetKind: "labResult",
|
||||
targetIds: [created.id],
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
invalidateUserHealthScore(user.id);
|
||||
const dto = serialiseLabResult(created, biomarker);
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* `GET /api/mood/prognosis` — the day's two readings, side by side.
|
||||
*
|
||||
* The person's own rating leads and is returned exactly as it was stored. The
|
||||
* forecast rides beside it with its band, the number of days it was built
|
||||
* from, and how old it is. The two are never merged: the distance between them
|
||||
* is the whole finding, and a single blended number would destroy it.
|
||||
*
|
||||
* **This route declares `record`, not `mind`, and that is the security
|
||||
* decision on it.** The payload is not only mood: the fit reads the day's
|
||||
* level-B context AND the figures the sleep, activity and recovery modules
|
||||
* hold, and the contributions it publishes name those features one by one — a
|
||||
* reader can see that sleep or steps or resting heart rate carried weight on a
|
||||
* given day, and roughly how much. Declared `mind` it would be a hole with a
|
||||
* mood-shaped label on it: a delegate holding only the mind section would
|
||||
* receive a derived view of two sections the owner had declined to share. The
|
||||
* convention for a read that crosses sections is `record`
|
||||
* (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest`
|
||||
* are the precedents) and a scoped grant never reaches one, so a scoped
|
||||
* delegate loses the forecast entirely rather than receiving a filtered
|
||||
* version of it. A filtered version would be worse: a contribution list with
|
||||
* the linked features quietly removed would tell the reader that the model
|
||||
* weighted only the mood fields, which is false.
|
||||
*
|
||||
* Nothing here writes. The rows come from the nightly job and the user cannot
|
||||
* overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts`
|
||||
* holds that, and this file is one of the ones it scans.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The parameter is declared although the handler body reads nothing off it,
|
||||
// and the reason is the exported type rather than the runtime. `apiHandler`
|
||||
// infers the type of `GET` from this callback, so a zero-argument handler
|
||||
// would type `GET` as taking no arguments — and Next.js, and the integration
|
||||
// tests, call it WITH the request. The wide event's method and path, and the
|
||||
// read-vs-write check `requireRecordAuth` runs, both come from the request the
|
||||
// wrapper receives from Next.js (not from this parameter), and both fall back
|
||||
// to `GET` when no request is present — so an absent request keeps a read a
|
||||
// read rather than escalating it.
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
// `record`: see the file header. A scoped grant is refused here by
|
||||
// construction rather than served a narrowed payload.
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "mood");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const reading = await readMoodPrognosis(user.id, new Date(), user.timezone);
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.prognosis.read" },
|
||||
meta: reading.present
|
||||
? {
|
||||
present: true,
|
||||
day: reading.date,
|
||||
fit_rows: reading.n,
|
||||
age_days: reading.ageDays,
|
||||
current: reading.current,
|
||||
provisional: reading.provisional,
|
||||
deviation: reading.deviation,
|
||||
}
|
||||
: { present: false, reason: reading.reason, entries: reading.entries },
|
||||
});
|
||||
|
||||
return apiSuccess(reading);
|
||||
});
|
||||
@@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
// Both plaintext columns, because a person looks a practice up by
|
||||
// whichever of the two they remember: the doctor's name on the referral
|
||||
// or the practice name on the door. Searching only the name made the
|
||||
// second attempt return nothing and read as "not in the address book".
|
||||
...(parsed.data.q
|
||||
? { name: { contains: parsed.data.q, mode: "insensitive" as const } }
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: parsed.data.q,
|
||||
mode: "insensitive" as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
practice: {
|
||||
contains: parsed.data.q,
|
||||
mode: "insensitive" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
|
||||
@@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
|
||||
* measurements): a pull invalidates the measurement-reminders read so the
|
||||
* list + server-computed next-due repaint.
|
||||
*
|
||||
* The page now holds two things, split by a segmented control rather than by a
|
||||
* second nav entry: the preventive-care list, and the visits. They answer the
|
||||
* same question from two directions — what do I still have to arrange, and what
|
||||
* did I actually attend — and the page's own sentence already covers both. A
|
||||
* visit carries no module gate for the same reason this page never had one.
|
||||
*
|
||||
* The pull invalidates both reads. Refreshing one and leaving the other stale
|
||||
* is how a page ends up showing a visit that closed a checkup still listed as
|
||||
* due.
|
||||
*/
|
||||
export default function CheckupsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [view, setView] = useState("vorsorge");
|
||||
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.measurementReminders(),
|
||||
}),
|
||||
invalidateKeys(queryClient, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.encounters(),
|
||||
]),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
@@ -41,7 +57,24 @@ export default function CheckupsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
|
||||
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
|
||||
{t("checkups.tabVorsorge")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</TabsContent>
|
||||
<TabsContent value="visits">
|
||||
{/* Mounted only on the active tab, so the visits read does not fire
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PractitionerList } from "@/components/practitioners/practitioner-list";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
|
||||
/**
|
||||
* The address book, one level under the checkups page the visits live on.
|
||||
*
|
||||
* Deliberately not in Settings. The Settings IA rule is that a settings surface
|
||||
* configures behaviour; a list of one's own doctors configures nothing and is
|
||||
* record content like every other clinical list. Sitting under `/checkups`
|
||||
* also means it inherits that page's route-family classification, so a
|
||||
* `profile`-scoped delegate reaches it exactly as they reach the visits.
|
||||
*/
|
||||
export default function PractitionersPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
|
||||
if (!mounted || isLoading) return <PageAuthGate />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PractitionerList enabled={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { EcgDetail } from "@/components/insights/ecg-detail";
|
||||
|
||||
/**
|
||||
* `/insights/ecg/[id]` — one ECG recording.
|
||||
*
|
||||
* The detail used to swap itself into the list card, with its own back
|
||||
* button sitting inside the card above the trace. That put the way back
|
||||
* BELOW the page heading, which is the one place a back control never
|
||||
* belongs, and it left the recording without an address: nothing to deep
|
||||
* link, nothing for the browser's own Back to return to.
|
||||
*
|
||||
* It is a route now, shaped exactly like `/insights/workouts/[id]`: a
|
||||
* `BackLink` above the `<SubPageShell>` title, and a body that fetches by
|
||||
* id so a cold deep link works without the list.
|
||||
*/
|
||||
export default function InsightsEcgDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
// No description. The one the list page carries describes the list,
|
||||
// and a detail page must not claim to be the list.
|
||||
backLink={
|
||||
<BackLink
|
||||
href="/insights/ecg"
|
||||
label={t("insights.ecg.backToList")}
|
||||
dataSlot="ecg-detail-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EcgDetail recordingId={id} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -63,21 +63,51 @@ function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) {
|
||||
}
|
||||
|
||||
describe("/insights/ecg page (H1)", () => {
|
||||
it("renders the EcgSection with its disclaimer when recordings exist", () => {
|
||||
it("renders the EcgSection when recordings exist", () => {
|
||||
const html = render({
|
||||
recordings: [seededRecording()],
|
||||
hasRecordings: true,
|
||||
});
|
||||
// The reused section + its load-bearing non-diagnostic disclaimer render.
|
||||
expect(html).toContain('data-slot="ecg-section"');
|
||||
expect(html).toContain('data-slot="ecg-card"');
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
// The shell owns the page heading (`<h1>`); the section's own
|
||||
// `<SectionHeading>` (`<h2>`) is suppressed via `hideHeading`.
|
||||
expect(html).toContain('id="insights-subpage-title"');
|
||||
expect(html).not.toContain("<h2");
|
||||
});
|
||||
|
||||
it("renders every recording — the full page passes no cap", () => {
|
||||
// The teaser on `/insights` caps at five; this dedicated page must not.
|
||||
// An account with many strips reaches all of them here, or the older ones
|
||||
// become unreachable with no pagination behind them. Eight in, eight out.
|
||||
const recordings = Array.from({ length: 8 }, (_, index) => ({
|
||||
...seededRecording(),
|
||||
id: `rec-${index}`,
|
||||
recordedAt: new Date(Date.UTC(2026, 6, 10 - index, 8, 0)).toISOString(),
|
||||
}));
|
||||
const html = render({ recordings, hasRecordings: true });
|
||||
|
||||
const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length;
|
||||
expect(rows).toBe(8);
|
||||
for (const rec of recordings) {
|
||||
expect(html, `${rec.id} must be reachable on the full page`).toContain(
|
||||
`/insights/ecg/${rec.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries no disclaimer paragraph above the list", () => {
|
||||
// Two muted lines used to lead this page — one from the shell's own
|
||||
// explainer slot and one hand-rolled beneath it, both saying HealthLog
|
||||
// does not read the curve. The page opens on the recordings now.
|
||||
const html = render({
|
||||
recordings: [seededRecording()],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).not.toContain("is not interpreted");
|
||||
});
|
||||
|
||||
it("shows the empty state (no section) when the account has no recordings", () => {
|
||||
const html = render({ recordings: [], hasRecordings: false });
|
||||
expect(html).not.toContain('data-slot="ecg-section"');
|
||||
|
||||
@@ -22,13 +22,14 @@ import { EcgSection } from "@/components/insights/ecg-section";
|
||||
* all. This page gives ECG its own address, reached from the Heart-group
|
||||
* pill in the tab strip (gated on `hasRecordings`) and the metric catalog.
|
||||
*
|
||||
* The surface itself is the existing `<EcgSection>`, reused verbatim — its
|
||||
* non-diagnostic disclaimer + waveform logic are load-bearing and untouched.
|
||||
* We only suppress its internal `<SectionHeading>` (via `hideHeading`) because
|
||||
* The surface itself is `<EcgSection>` — the list of strips. Its internal
|
||||
* `<SectionHeading>` is suppressed (via `hideHeading`) because
|
||||
* `<SubPageShell>` already renders the page heading, mirroring how every other
|
||||
* routed sub-page lets the shell own the `<h1>`.
|
||||
*
|
||||
* The overview `<EcgSection>` teaser on `/insights` stays as-is.
|
||||
* A row opens `/insights/ecg/[id]`, which is where the trace, the metadata and
|
||||
* the device's own result live. The overview `<EcgSection>` teaser on
|
||||
* `/insights` links to the same place.
|
||||
*/
|
||||
export default function InsightsEcgPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -76,11 +77,6 @@ export default function InsightsEcgPage() {
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
description={t("insights.subPage.ecgDescription")}
|
||||
>
|
||||
{/* What the app does and does not claim about a trace. Its own
|
||||
paragraph, so the page explainer above stays one sentence. */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.subPage.ecgDisclaimer")}
|
||||
</p>
|
||||
<EcgSection enabled={isAuthenticated} hideHeading />
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,21 @@ const MoodChart = dynamic(
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
/**
|
||||
* The day's two readings. Deferred like every other block on this route that
|
||||
* is not the first paint: it reads its own endpoint and renders below the
|
||||
* calendar, so nothing about it belongs in the eager graph of a page whose
|
||||
* first paint is a heatmap. This route sits close to its bundle ceiling and
|
||||
* every addition to it goes through the same treatment.
|
||||
*/
|
||||
const MoodPrognosisCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({
|
||||
default: mod.MoodPrognosisCard,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
interface ComprehensiveMoodData {
|
||||
moodSummary: { count: number } | null;
|
||||
}
|
||||
@@ -137,6 +152,12 @@ export default function InsightsStimmungPage() {
|
||||
|
||||
<MetricTargetSummary slug="mood" />
|
||||
|
||||
{/* The day's own rating beside what the account's past days imply. It
|
||||
sits high because the self-assessment is the leading value on this
|
||||
page, and directly under the line chart because that is where a
|
||||
reader has just seen the rating it leads with. */}
|
||||
<MoodPrognosisCard />
|
||||
|
||||
{/* The better-days Einschätzung sits directly under the Ziel card,
|
||||
ahead of the classification tiles and breakdowns. */}
|
||||
<MoodInsightsSections region="assessment" />
|
||||
|
||||
@@ -9,6 +9,9 @@ import { SlidersHorizontal, TrendingUp } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
// Pure constant — importing it here does NOT pull the dynamically-loaded ECG
|
||||
// component into this bundle, which is exactly why it lives in the lib module.
|
||||
import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-classification";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
@@ -527,7 +530,7 @@ export default function InsightsPageClient() {
|
||||
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
|
||||
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
|
||||
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
|
||||
ecg: <EcgSection enabled={isAuthenticated} />,
|
||||
ecg: <EcgSection enabled={isAuthenticated} limit={ECG_OVERVIEW_LIMIT} />,
|
||||
};
|
||||
|
||||
const orderedSectionIds = orderedVisibleSectionIds(layout);
|
||||
|
||||
@@ -53,6 +53,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -36,6 +36,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "write" as const,
|
||||
level: "write" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
CalendarCheck,
|
||||
Award,
|
||||
CalendarClock,
|
||||
Footprints,
|
||||
@@ -55,6 +56,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
|
||||
ecg_new_recording: HeartPulse,
|
||||
tension_window: Activity,
|
||||
same_time_baseline: Footprints,
|
||||
upcoming_visit: CalendarCheck,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,22 +4,30 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
/**
|
||||
* The floating bulk bar, pinned via single-pass static renders: a labelled
|
||||
* toolbar carrying the selected count and the bulk verbs (set type, link
|
||||
* condition, share, delete, clear). The link-condition menu only renders when
|
||||
* the account actually has episodes to link — no dead affordance on an
|
||||
* episode-free account.
|
||||
* condition, file against a visit, share, delete, clear). Each link menu only
|
||||
* renders when the account actually has something to link to — no dead
|
||||
* affordance on an account with no episodes, and none on an account with no
|
||||
* visits either.
|
||||
*/
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentBulkBar } from "../document-bulk-bar";
|
||||
|
||||
function render(episodes: { id: string; label: string }[]) {
|
||||
function render(
|
||||
episodes: { id: string; label: string }[],
|
||||
encounters: { id: string; label: string }[] = [
|
||||
{ id: "v1", label: "Routine visit · 01/08/2026" },
|
||||
],
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentBulkBar
|
||||
selectedCount={3}
|
||||
episodes={episodes}
|
||||
encounters={encounters}
|
||||
busy={false}
|
||||
onSetKind={() => {}}
|
||||
onLinkEpisode={() => {}}
|
||||
onLinkEncounter={() => {}}
|
||||
onShare={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClear={() => {}}
|
||||
@@ -36,6 +44,7 @@ describe("<DocumentBulkBar>", () => {
|
||||
expect(html).toContain("3 selected");
|
||||
expect(html).toContain("Change type");
|
||||
expect(html).toContain("Link condition");
|
||||
expect(html).toContain("File against visit");
|
||||
expect(html).toContain('data-slot="document-bulk-share"');
|
||||
expect(html).toContain("Share");
|
||||
expect(html).toContain("Delete");
|
||||
@@ -47,4 +56,13 @@ describe("<DocumentBulkBar>", () => {
|
||||
expect(html).not.toContain("Link condition");
|
||||
expect(html).toContain("Change type");
|
||||
});
|
||||
|
||||
it("omits the file-against-visit verb when the account has no visits", () => {
|
||||
const html = render([{ id: "ep1", label: "Knee" }], []);
|
||||
expect(html).not.toContain("File against visit");
|
||||
expect(html).not.toContain('data-slot="document-bulk-link-visit"');
|
||||
// The positive control: the neighbouring verb is still there, so the
|
||||
// assertion above is about the visit menu and not about an empty render.
|
||||
expect(html).toContain("Link condition");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -50,6 +50,7 @@ function doc(id: string, title: string): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep1", name: "Knee" }],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -38,6 +38,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
* Floating bulk-action bar for the vault's multi-select mode. Appears once
|
||||
* at least one document is selected, pinned above the bottom nav on phones
|
||||
* and near the bottom edge on desktop. Carries the selected count and the
|
||||
* bulk verbs — set type, link condition, share (one link for the whole
|
||||
* selection), delete (undo-able), clear — all driving the page's handlers.
|
||||
* bulk verbs — set type, link condition, file against a visit, share (one
|
||||
* link for the whole selection), delete (undo-able), clear — all driving the
|
||||
* page's handlers.
|
||||
*
|
||||
* The two link verbs are the same shape and stay that way: each renders only
|
||||
* when the account HAS something to link to, so an empty menu is never
|
||||
* offered.
|
||||
*
|
||||
* A `role="toolbar"` with proper labels; the destructive verb sits last
|
||||
* before Clear so a stray tap sequence never ends on Delete. Share sits just
|
||||
@@ -13,7 +18,14 @@
|
||||
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
|
||||
* per the standards.
|
||||
*/
|
||||
import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
FolderPlus,
|
||||
Share2,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -32,12 +44,20 @@ export interface BulkEpisodeOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** One visit the selection can be filed against. Label resolved by the page. */
|
||||
export interface BulkEncounterOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DocumentBulkBar({
|
||||
selectedCount,
|
||||
episodes,
|
||||
encounters,
|
||||
busy,
|
||||
onSetKind,
|
||||
onLinkEpisode,
|
||||
onLinkEncounter,
|
||||
onShare,
|
||||
onDelete,
|
||||
onClear,
|
||||
@@ -45,10 +65,13 @@ export function DocumentBulkBar({
|
||||
selectedCount: number;
|
||||
/** The caller's live illness episodes — the link-condition targets. */
|
||||
episodes: BulkEpisodeOption[];
|
||||
/** The caller's recent visits — the file-against-a-visit targets. */
|
||||
encounters: BulkEncounterOption[];
|
||||
/** A bulk call is in flight — the verbs disable, Clear stays live. */
|
||||
busy: boolean;
|
||||
onSetKind: (kind: InboundDocumentKindValue) => void;
|
||||
onLinkEpisode: (episodeId: string) => void;
|
||||
onLinkEncounter: (encounterId: string) => void;
|
||||
/**
|
||||
* Share the whole selection as ONE documents-only link. The page caps the
|
||||
* selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint —
|
||||
@@ -135,6 +158,35 @@ export function DocumentBulkBar({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{encounters.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
data-slot="document-bulk-link-visit"
|
||||
aria-label={t("documents.bulk.linkVisit")}
|
||||
>
|
||||
<CalendarClock className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.bulk.linkVisit")}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{encounters.map((encounter) => (
|
||||
<DropdownMenuItem
|
||||
key={encounter.id}
|
||||
onSelect={() => onLinkEncounter(encounter.id)}
|
||||
>
|
||||
<span className="max-w-56 truncate">{encounter.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -77,6 +79,7 @@ import {
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "./document-ai-section";
|
||||
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
|
||||
import { DocumentSummaryBlock } from "./document-summary-block";
|
||||
import type { DocumentAiTarget } from "./document-ai-transport";
|
||||
import { DocumentShareSheet } from "./document-share-sheet";
|
||||
@@ -96,6 +99,7 @@ type PatchInput = {
|
||||
kind?: InboundDocumentKindValue;
|
||||
documentDate?: string | null;
|
||||
episodeIds?: string[];
|
||||
encounterIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -396,6 +400,17 @@ export function DocumentDetailSheet({
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
/**
|
||||
* The visit this SESSION filed the document against, if any.
|
||||
*
|
||||
* Kept beside the server state rather than derived from it, because the two
|
||||
* answer different questions: `doc.encounterLinks` says what is filed, this
|
||||
* says what the person just did here — which is what an undo can take back
|
||||
* without touching a filing they made somewhere else.
|
||||
*/
|
||||
const [pickedEncounterId, setPickedEncounterId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
|
||||
null,
|
||||
@@ -499,6 +514,25 @@ export function DocumentDetailSheet({
|
||||
patch.mutate({ episodeIds: next });
|
||||
};
|
||||
|
||||
/**
|
||||
* File this document against a visit, or clear that filing again.
|
||||
*
|
||||
* A replace-set PATCH like the condition links, so the undo is the same
|
||||
* write with the id removed rather than a second endpoint. Nothing here can
|
||||
* fail the document: an unknown id is refused by the route before anything
|
||||
* is written, and the sheet keeps showing what the server last confirmed.
|
||||
*/
|
||||
const setSuggestedEncounter = (encounterId: string | null) => {
|
||||
if (!doc) return;
|
||||
const current = doc.encounterLinks.map((l) => l.encounterId);
|
||||
const next =
|
||||
encounterId === null
|
||||
? current.filter((id) => id !== pickedEncounterId)
|
||||
: [...new Set([...current, encounterId])];
|
||||
setPickedEncounterId(encounterId);
|
||||
patch.mutate({ encounterIds: next });
|
||||
};
|
||||
|
||||
// The affordance gate falls back to the capability probe when the sheet is
|
||||
// rendered without the usage-derived props (deep link before usage loads).
|
||||
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
|
||||
@@ -1001,6 +1035,47 @@ export function DocumentDetailSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentEncounterSuggestion
|
||||
anchor={doc.reportDate ?? doc.documentDate}
|
||||
alreadyLinked={
|
||||
doc.encounterLinks.length > 0 && pickedEncounterId === null
|
||||
}
|
||||
disabled={!canManage}
|
||||
value={pickedEncounterId}
|
||||
onChange={setSuggestedEncounter}
|
||||
/>
|
||||
|
||||
{/* "Belongs to visit" — read-only here on purpose. The filing
|
||||
happens at the moment the document arrives, or from the
|
||||
visit's own sheet; a second editor for the same link would
|
||||
be a second place the set can be changed. Each row deep-links
|
||||
to the vault filtered to that visit. */}
|
||||
{doc.encounterLinks.length > 0 ? (
|
||||
<div className="space-y-1.5" data-slot="document-visit-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.visitsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{encounterKindText(t, link.kind as EncounterKind)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{labsModuleEnabled && labFactCount > 0 ? (
|
||||
<Link
|
||||
href="/labs"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The document moment of the visit suggestion.
|
||||
*
|
||||
* The rule and the rendering both live elsewhere — `suggest-window.ts` on the
|
||||
* server and `EncounterSuggestionField` in the browser — so this file is only
|
||||
* the wiring: which date to anchor on, and where the chosen id goes.
|
||||
*
|
||||
* The anchor is `reportDate ?? documentDate`. `reportDate` is what the model
|
||||
* transcribed off the letter itself and is never inferred, so it is the truer
|
||||
* of the two; `documentDate` is the person's own filing date and is the
|
||||
* fallback.
|
||||
*
|
||||
* It renders nothing once the document already names a visit that this session
|
||||
* did not choose: an offer to file something already filed is noise.
|
||||
*/
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
|
||||
export function DocumentEncounterSuggestion({
|
||||
anchor,
|
||||
alreadyLinked,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
anchor: string | null;
|
||||
alreadyLinked: boolean;
|
||||
disabled?: boolean;
|
||||
value: string | null;
|
||||
onChange: (encounterId: string | null) => void;
|
||||
}) {
|
||||
if (alreadyLinked) return null;
|
||||
return (
|
||||
<EncounterSuggestionField
|
||||
anchor={anchor}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slot="document-encounter-suggestion"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,10 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import {
|
||||
type CoachCloseIntent,
|
||||
useCoachLaunch,
|
||||
@@ -85,6 +88,13 @@ interface ListPage {
|
||||
}
|
||||
|
||||
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
|
||||
/**
|
||||
* How many visits the bulk bar offers. A menu is a picker, not a history: past
|
||||
* the first screenful a person scrolls rather than reads, and filing against a
|
||||
* visit older than this belongs on the visit's own sheet.
|
||||
*/
|
||||
const BULK_VISIT_OPTIONS = 12;
|
||||
|
||||
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
|
||||
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
|
||||
|
||||
@@ -226,6 +236,7 @@ export function closeDocumentSelectionAfterCoachHandoff(
|
||||
|
||||
export function DocumentsView() {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
@@ -476,6 +487,27 @@ export function DocumentsView() {
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
|
||||
|
||||
// The visits the bulk bar can file a selection against: the account's own,
|
||||
// read through the same list the checkups page shows. Labelled here rather
|
||||
// than in the bar so the label uses the reader's locale for the kind and the
|
||||
// reader's timezone for the date — both presentation, both this side's job.
|
||||
const visits = useEncounters(
|
||||
!inSharedRecord || sections === null || sections.includes("profile"),
|
||||
);
|
||||
const bulkEncounterOptions = useMemo(
|
||||
() =>
|
||||
[...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])]
|
||||
.slice(0, BULK_VISIT_OPTIONS)
|
||||
.map((visit) => ({
|
||||
id: visit.id,
|
||||
label: `${
|
||||
visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)
|
||||
} · ${format.date(visit.occurredAt)}`,
|
||||
})),
|
||||
[visits.data, t, format],
|
||||
);
|
||||
|
||||
// Year segmenter: years present in the loaded corpus (+ the active year).
|
||||
const years = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
@@ -589,7 +621,11 @@ export function DocumentsView() {
|
||||
const runBulk = useCallback(
|
||||
(
|
||||
action: Exclude<DocumentBulkAction, "delete" | "restore">,
|
||||
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
|
||||
extra: {
|
||||
kind?: InboundDocumentKindValue;
|
||||
episodeId?: string;
|
||||
encounterId?: string;
|
||||
},
|
||||
) => {
|
||||
const ids = [...selectedIds];
|
||||
bulk.mutate(
|
||||
@@ -957,6 +993,10 @@ export function DocumentsView() {
|
||||
busy={bulk.isPending}
|
||||
onSetKind={(kind) => runBulk("setKind", { kind })}
|
||||
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
|
||||
encounters={bulkEncounterOptions}
|
||||
onLinkEncounter={(encounterId) =>
|
||||
runBulk("linkEncounter", { encounterId })
|
||||
}
|
||||
onShare={openBulkShare}
|
||||
onDelete={() => deleteBulk([...selectedIds])}
|
||||
onClear={clearSelection}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
|
||||
|
||||
/**
|
||||
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
|
||||
* Parse the vault's URL search params (`?q&kind&episode&encounter&year`) into the
|
||||
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
|
||||
* page), `kind` accepts repeats AND comma-separated values, and the result is
|
||||
* normalised (kinds sorted, empties omitted) so the same view always produces
|
||||
@@ -42,6 +42,13 @@ export function parseVaultSearchParams(
|
||||
const episode = params.get("episode")?.trim();
|
||||
if (episode) filters.episodeId = episode.slice(0, 40);
|
||||
|
||||
// `?encounter=` is how a visit's own detail row reaches the vault filtered
|
||||
// to what that visit produced. No chip in the filter bar backs it: the facet
|
||||
// arrives by deep link from the visit and clears with the clear-all pill,
|
||||
// which is exactly what a one-way link needs and no more.
|
||||
const encounter = params.get("encounter")?.trim();
|
||||
if (encounter) filters.encounterId = encounter.slice(0, 40);
|
||||
|
||||
const yearRaw = params.get("year");
|
||||
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
|
||||
const year = Number(yearRaw);
|
||||
@@ -64,6 +71,7 @@ export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
|
||||
sp.set("kind", [...filters.kinds].sort().join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episode", filters.episodeId);
|
||||
if (filters.encounterId) sp.set("encounter", filters.encounterId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
return sp.toString();
|
||||
}
|
||||
@@ -74,6 +82,7 @@ export function countActiveFilters(filters: DocumentVaultFilters): number {
|
||||
if (filters.q) count += 1;
|
||||
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
|
||||
if (filters.episodeId) count += 1;
|
||||
if (filters.encounterId) count += 1;
|
||||
if (filters.year !== undefined) count += 1;
|
||||
return count;
|
||||
}
|
||||
@@ -95,6 +104,7 @@ export function buildVaultListApiSearch(
|
||||
sp.set("kind", filters.kinds.join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
|
||||
if (filters.encounterId) sp.set("encounterId", filters.encounterId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The one product constraint this whole feature exists to hold: **a visit
|
||||
* saves with a date and nothing else.**
|
||||
*
|
||||
* Three properties, each of which has an obvious way to get broken by a later
|
||||
* "improvement":
|
||||
*
|
||||
* - no field but the date can block Save;
|
||||
* - the status is DERIVED from the date on a new visit, never asked;
|
||||
* - a link picker whose module is off is BLANK, not empty — an empty picker
|
||||
* for a switched-off module advertises a feature that is not there.
|
||||
*/
|
||||
|
||||
let modules: Record<string, boolean> = {};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
EncounterForm,
|
||||
draftFromEncounter,
|
||||
derivedStatus,
|
||||
draftInstant,
|
||||
draftToBody,
|
||||
emptyDraft,
|
||||
} from "../encounter-form";
|
||||
|
||||
/**
|
||||
* The form body, rendered directly rather than through its sheet: the sheet is
|
||||
* a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a
|
||||
* test that went through it would assert against an empty string and pass for
|
||||
* the wrong reason.
|
||||
*/
|
||||
function renderForm(over?: {
|
||||
modules?: Record<string, boolean>;
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
modules = over?.modules ?? {};
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<EncounterForm
|
||||
draft={emptyDraft({ occurredAt: "2026-08-08T10:00" })}
|
||||
onChange={() => {}}
|
||||
isEdit={over?.isEdit ?? false}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("nothing but the date is required", () => {
|
||||
it("admits a draft carrying only a date", () => {
|
||||
// `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft
|
||||
// with no practitioner, no reason, no outcome and no link is saveable.
|
||||
const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" });
|
||||
expect(draft.practitioner).toBeNull();
|
||||
expect(draft.reason).toBe("");
|
||||
expect(draft.documentIds).toEqual([]);
|
||||
expect(draftInstant(draft.occurredAt)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses only when the date itself cannot be read", () => {
|
||||
expect(draftInstant("not a date")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the date field required and nothing else", () => {
|
||||
const html = renderForm();
|
||||
const required = html.match(/aria-required="true"/g) ?? [];
|
||||
expect(required).toHaveLength(1);
|
||||
expect(html).toContain('data-testid="encounter-occurred-at"');
|
||||
});
|
||||
|
||||
it("turns a date-only draft into a complete write body", () => {
|
||||
const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), {
|
||||
isEdit: false,
|
||||
});
|
||||
expect(body).not.toBeNull();
|
||||
expect(body?.practitionerId).toBeNull();
|
||||
expect(body?.reason).toBeNull();
|
||||
expect(body?.outcome).toBeNull();
|
||||
expect(body?.documentIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to build a body from a date that does not parse", () => {
|
||||
expect(
|
||||
draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing never unfiles what the visit already collected", () => {
|
||||
it("seeds the link arrays from the row's own links", () => {
|
||||
// The defect this pins: an edit seeded from a row that did not know its
|
||||
// links would save a replace-set of three EMPTY arrays and silently
|
||||
// unfile everything. The list carries the links so this cannot happen.
|
||||
const draft = draftFromEncounter({
|
||||
id: "e1",
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
links: {
|
||||
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
|
||||
labResults: [{ id: "l1", label: "LDL", date: null, redacted: false }],
|
||||
conditions: [{ id: "c1", label: "Knee", date: null, redacted: false }],
|
||||
},
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
});
|
||||
expect(draft.documentIds).toEqual(["d1"]);
|
||||
expect(draft.labResultIds).toEqual(["l1"]);
|
||||
expect(draft.episodeIds).toEqual(["c1"]);
|
||||
|
||||
const body = draftToBody(draft, { isEdit: true });
|
||||
expect(body?.documentIds).toEqual(["d1"]);
|
||||
expect(body?.labResultIds).toEqual(["l1"]);
|
||||
expect(body?.episodeIds).toEqual(["c1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the status follows the date on a new visit", () => {
|
||||
const now = Date.parse("2026-08-08T12:00:00.000Z");
|
||||
|
||||
it("reads a future instant as a booked appointment", () => {
|
||||
expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED");
|
||||
});
|
||||
|
||||
it("reads a past instant as a visit that happened", () => {
|
||||
expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE");
|
||||
});
|
||||
|
||||
it("never asks for the status on a new visit", () => {
|
||||
// The picker is what "asked" would look like; its absence is the property.
|
||||
expect(renderForm()).not.toContain('id="encounter-status"');
|
||||
});
|
||||
|
||||
it("carries the derived status into the body rather than the draft default", () => {
|
||||
const body = draftToBody(
|
||||
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }),
|
||||
{ isEdit: false },
|
||||
);
|
||||
expect(body?.status).toBe("PLANNED");
|
||||
});
|
||||
|
||||
it("uses the draft's own status on an edit, where it is editable", () => {
|
||||
const body = draftToBody(
|
||||
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }),
|
||||
{ isEdit: true },
|
||||
);
|
||||
expect(body?.status).toBe("CANCELLED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the link pickers follow the modules that own their targets", () => {
|
||||
it("renders no link block at all when all three modules are off", () => {
|
||||
const html = renderForm({ modules: {} });
|
||||
expect(html).not.toContain('data-slot="encounter-link-pickers"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-documents"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-conditions"');
|
||||
});
|
||||
|
||||
it("blanks the block of a disabled module while keeping its neighbours", () => {
|
||||
const html = renderForm({
|
||||
modules: { inboundDocuments: true, labs: false, illness: false },
|
||||
});
|
||||
expect(html).toContain('data-slot="encounter-link-documents"');
|
||||
// Not "renders an empty list" — absent.
|
||||
expect(html).not.toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-conditions"');
|
||||
});
|
||||
|
||||
it("offers all three when all three modules are on", () => {
|
||||
const html = renderForm({
|
||||
modules: { inboundDocuments: true, labs: true, illness: true },
|
||||
});
|
||||
expect(html).toContain('data-slot="encounter-link-documents"');
|
||||
expect(html).toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).toContain('data-slot="encounter-link-conditions"');
|
||||
// The positive control: without it the three assertions above would pass
|
||||
// against a component that never renders any block.
|
||||
expect(html).toContain('data-slot="encounter-link-pickers"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The visits surface's designed states, pinned by single-pass static renders.
|
||||
*
|
||||
* - an account with no visits paints the empty state and one action, not an
|
||||
* error and not a teaching wall;
|
||||
* - upcoming sorts above past, in the two lists the SERVER split — the
|
||||
* client renders the order it was given rather than re-deriving one;
|
||||
* - a soft-deleted visit never arrives, so it cannot be rendered. That is a
|
||||
* server property and the API test owns it; what this file pins is that
|
||||
* the section renders exactly what the response carried, with nothing
|
||||
* filtered or re-sorted on this side that could disagree with it.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
// Never resolves inside a synchronous render, so an unseeded query stays
|
||||
// pending and cannot accidentally satisfy an assertion.
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { VisitsSection } from "../visits-section";
|
||||
|
||||
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
list,
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<VisitsSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<VisitsSection>", () => {
|
||||
it("paints the empty state, with one action, when there is no visit", () => {
|
||||
const html = render({ upcoming: [], past: [] });
|
||||
expect(html).toContain("No visit recorded yet");
|
||||
expect(html).toContain("A date is enough");
|
||||
expect(html).toContain('data-slot="visits-add"');
|
||||
// An empty account is not an error, and the two states never share copy.
|
||||
expect(html).not.toContain("Visits could not be loaded.");
|
||||
expect(html).not.toContain('data-slot="visits-upcoming"');
|
||||
});
|
||||
|
||||
it("puts the upcoming list above the past one", () => {
|
||||
const html = render({
|
||||
upcoming: [visit({ id: "later", status: "PLANNED" })],
|
||||
past: [visit({ id: "earlier" })],
|
||||
});
|
||||
const upcomingAt = html.indexOf('data-slot="visits-upcoming"');
|
||||
const pastAt = html.indexOf('data-slot="visits-past"');
|
||||
expect(upcomingAt).toBeGreaterThan(-1);
|
||||
expect(pastAt).toBeGreaterThan(-1);
|
||||
expect(upcomingAt).toBeLessThan(pastAt);
|
||||
expect(html.indexOf('data-encounter-id="later"')).toBeLessThan(
|
||||
html.indexOf('data-encounter-id="earlier"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the server's order inside a list rather than re-sorting it", () => {
|
||||
// Two rows deliberately handed over in an order the client has no reason
|
||||
// to like. Re-sorting here would be the second place the ordering lives.
|
||||
const html = render({
|
||||
upcoming: [],
|
||||
past: [
|
||||
visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }),
|
||||
visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }),
|
||||
],
|
||||
});
|
||||
expect(html.indexOf('data-encounter-id="second"')).toBeLessThan(
|
||||
html.indexOf('data-encounter-id="first"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts what a visit produced, from the links the LIST carried", () => {
|
||||
// The counts come off `links`, and the list used to omit them — every card
|
||||
// then read zero for a visit with three documents. The fixture carries a
|
||||
// populated `links` because the response now does.
|
||||
const html = render({
|
||||
upcoming: [],
|
||||
past: [
|
||||
visit({
|
||||
id: "linked",
|
||||
links: {
|
||||
documents: [
|
||||
{ id: "d1", label: "Letter", date: null, redacted: false },
|
||||
{ id: "d2", label: "Scan", date: null, redacted: false },
|
||||
],
|
||||
labResults: [
|
||||
{ id: "l1", label: "LDL", date: null, redacted: false },
|
||||
],
|
||||
conditions: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(html).toContain("2 linked documents");
|
||||
expect(html).toContain("1 linked lab results");
|
||||
// The empty family renders no counter rather than a zero.
|
||||
expect(html).not.toContain("0 linked conditions");
|
||||
});
|
||||
|
||||
it("offers the address book beside the add action", () => {
|
||||
const html = render({ upcoming: [], past: [] });
|
||||
expect(html).toContain('href="/checkups/practitioners"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The visit form.
|
||||
*
|
||||
* **A visit saves with a date and nothing else.** No required practitioner, no
|
||||
* required kind, no required link, and no Save button disabled until something
|
||||
* is picked. That is the whole product constraint this feature exists to hold:
|
||||
* a required field on a form somebody fills after a doctor's appointment is the
|
||||
* bureaucracy it was built to avoid. The only thing that can block Save is a
|
||||
* missing or unparseable date, because a visit with no instant is not a record
|
||||
* of anything.
|
||||
*
|
||||
* Status is DERIVED from the date on a new visit, never asked: a future instant
|
||||
* means PLANNED and a past one means DONE. A person booking an appointment
|
||||
* should not have to also tell the app that it is in the future. On an existing
|
||||
* visit the status becomes editable, because moving a planned visit to done,
|
||||
* cancelled or a no-show is the transition that closes a checkup.
|
||||
*
|
||||
* The three link pickers are gated by the modules that own what they point at,
|
||||
* and the gate BLANKS the block rather than post-filtering it — an empty
|
||||
* picker for a module the account turned off advertises a feature that is not
|
||||
* there.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
|
||||
import type { Practitioner } from "@/hooks/use-practitioners";
|
||||
import {
|
||||
ENCOUNTER_KINDS,
|
||||
ENCOUNTER_STATUSES,
|
||||
encounterKindText,
|
||||
encounterStatusText,
|
||||
} from "./encounter-labels";
|
||||
import { PractitionerCombobox } from "./practitioner-combobox";
|
||||
import { EncounterLinkPickers } from "./encounter-link-pickers";
|
||||
|
||||
export interface EncounterDraft {
|
||||
/** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */
|
||||
occurredAt: string;
|
||||
status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
||||
kind: string;
|
||||
practitioner: Practitioner | null;
|
||||
reason: string;
|
||||
outcome: string;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
}
|
||||
|
||||
function localValue(instant: Date): string {
|
||||
const offset = instant.getTimezoneOffset();
|
||||
return new Date(instant.getTime() - offset * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
export function nowLocalValue(): string {
|
||||
return localValue(new Date());
|
||||
}
|
||||
|
||||
/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */
|
||||
export function draftInstant(value: string): string | null {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function emptyDraft(
|
||||
overrides?: Partial<EncounterDraft>,
|
||||
): EncounterDraft {
|
||||
return {
|
||||
occurredAt: nowLocalValue(),
|
||||
status: "DONE",
|
||||
kind: "OTHER",
|
||||
practitioner: null,
|
||||
reason: "",
|
||||
outcome: "",
|
||||
documentIds: [],
|
||||
labResultIds: [],
|
||||
episodeIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function draftFromEncounter(row: Encounter): EncounterDraft {
|
||||
return {
|
||||
occurredAt: localValue(new Date(row.occurredAt)),
|
||||
status: row.status,
|
||||
kind: row.kind,
|
||||
practitioner: row.practitioner,
|
||||
reason: row.reason ?? "",
|
||||
outcome: row.outcome ?? "",
|
||||
documentIds: row.links?.documents.map((link) => link.id) ?? [],
|
||||
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
|
||||
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The status a NEW visit carries, read off its own date.
|
||||
*
|
||||
* Exported so the sheet and its tests agree on the derivation rather than each
|
||||
* spelling out the comparison.
|
||||
*/
|
||||
export function derivedStatus(
|
||||
occurredAt: string,
|
||||
now = Date.now(),
|
||||
): "PLANNED" | "DONE" {
|
||||
const parsed = new Date(occurredAt).getTime();
|
||||
return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE";
|
||||
}
|
||||
|
||||
/** The body a draft becomes, with the empty strings turned into absences. */
|
||||
export function draftToBody(
|
||||
draft: EncounterDraft,
|
||||
options: { isEdit: boolean; reminderId?: string | null },
|
||||
): EncounterWriteBody | null {
|
||||
const occurredAt = draftInstant(draft.occurredAt);
|
||||
if (!occurredAt) return null;
|
||||
return {
|
||||
occurredAt,
|
||||
status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt),
|
||||
kind: draft.kind,
|
||||
practitionerId: draft.practitioner?.id ?? null,
|
||||
reason: draft.reason.trim() || null,
|
||||
outcome: draft.outcome.trim() || null,
|
||||
...(options.reminderId ? { reminderId: options.reminderId } : {}),
|
||||
documentIds: draft.documentIds,
|
||||
labResultIds: draft.labResultIds,
|
||||
episodeIds: draft.episodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function EncounterForm({
|
||||
draft,
|
||||
onChange,
|
||||
isEdit,
|
||||
}: {
|
||||
draft: EncounterDraft;
|
||||
onChange: (next: EncounterDraft) => void;
|
||||
isEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const patch = (part: Partial<EncounterDraft>) =>
|
||||
onChange({ ...draft, ...part });
|
||||
|
||||
const planned = useMemo(
|
||||
() =>
|
||||
isEdit
|
||||
? draft.status === "PLANNED"
|
||||
: derivedStatus(draft.occurredAt) === "PLANNED",
|
||||
[isEdit, draft.status, draft.occurredAt],
|
||||
);
|
||||
|
||||
// The outcome field starts collapsed on a planned visit: there is nothing to
|
||||
// write about a thing that has not happened. Derived rather than
|
||||
// synchronised — a visit that already carries an outcome is never collapsed
|
||||
// over it, and expressing that as an effect would be a second source of
|
||||
// truth for something the draft already knows.
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="encounter-form">
|
||||
<FieldGroup
|
||||
htmlFor="encounter-occurred-at"
|
||||
label={t("encounters.form.date")}
|
||||
hint={isEdit ? undefined : t("encounters.form.dateDerivesStatusHint")}
|
||||
>
|
||||
<DateTimeField
|
||||
id="encounter-occurred-at"
|
||||
value={draft.occurredAt}
|
||||
onChange={(value) => patch({ occurredAt: value })}
|
||||
required
|
||||
aria-required="true"
|
||||
data-testid="encounter-occurred-at"
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{isEdit ? (
|
||||
<FieldGroup
|
||||
htmlFor="encounter-status"
|
||||
label={t("encounters.form.status")}
|
||||
>
|
||||
<NativeSelect
|
||||
id="encounter-status"
|
||||
value={draft.status}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
status: event.target.value as EncounterDraft["status"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{ENCOUNTER_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{encounterStatusText(t, status)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="encounter-practitioner"
|
||||
label={t("encounters.form.practitioner")}
|
||||
hint={t("encounters.form.practitionerHint")}
|
||||
>
|
||||
<PractitionerCombobox
|
||||
value={draft.practitioner?.id ?? null}
|
||||
selected={draft.practitioner}
|
||||
onChange={(next) => patch({ practitioner: next })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup htmlFor="encounter-kind" label={t("encounters.form.kind")}>
|
||||
<NativeSelect
|
||||
id="encounter-kind"
|
||||
value={draft.kind}
|
||||
onChange={(event) => patch({ kind: event.target.value })}
|
||||
>
|
||||
{ENCOUNTER_KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{encounterKindText(t, kind)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="encounter-reason"
|
||||
label={t("encounters.form.reason")}
|
||||
>
|
||||
<Textarea
|
||||
id="encounter-reason"
|
||||
rows={2}
|
||||
value={draft.reason}
|
||||
onChange={(event) => patch({ reason: event.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{outcomeOpen ? (
|
||||
<FieldGroup
|
||||
htmlFor="encounter-outcome"
|
||||
label={t("encounters.form.outcome")}
|
||||
>
|
||||
<Textarea
|
||||
id="encounter-outcome"
|
||||
rows={3}
|
||||
value={draft.outcome}
|
||||
onChange={(event) => patch({ outcome: event.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-outcome-reveal"
|
||||
onClick={() => setRevealed(true)}
|
||||
>
|
||||
{t("encounters.form.outcomeReveal")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<EncounterLinkPickers
|
||||
modules={user?.modules}
|
||||
documentIds={draft.documentIds}
|
||||
labResultIds={draft.labResultIds}
|
||||
episodeIds={draft.episodeIds}
|
||||
onChange={patch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Browser-side labels for a visit's kind and status.
|
||||
*
|
||||
* The server has its own `encounterKindLabel` and the two do not share code on
|
||||
* purpose: the server's answer becomes a push body in the RECORD OWNER's
|
||||
* language, and this one renders in the language of whoever is looking. Sharing
|
||||
* a resolver would force one of the two to read the wrong locale.
|
||||
*
|
||||
* Both switches spell out literal `t()` calls rather than interpolating a key.
|
||||
* An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
|
||||
* or missing label would ship as raw dot notation instead of failing CI.
|
||||
*/
|
||||
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
|
||||
/** Every kind, in the order the picker offers them. */
|
||||
export const ENCOUNTER_KINDS: EncounterKind[] = [
|
||||
"ROUTINE",
|
||||
"ACUTE",
|
||||
"SPECIALIST",
|
||||
"PREVENTIVE",
|
||||
"EMERGENCY",
|
||||
"HOSPITAL",
|
||||
"THERAPY",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
/** Every status a visit can be edited into. */
|
||||
export const ENCOUNTER_STATUSES: EncounterStatus[] = [
|
||||
"PLANNED",
|
||||
"DONE",
|
||||
"CANCELLED",
|
||||
"NO_SHOW",
|
||||
];
|
||||
|
||||
export function encounterKindText(t: Translate, kind: EncounterKind): string {
|
||||
switch (kind) {
|
||||
case "ROUTINE":
|
||||
return t("encounters.kind.routine");
|
||||
case "ACUTE":
|
||||
return t("encounters.kind.acute");
|
||||
case "SPECIALIST":
|
||||
return t("encounters.kind.specialist");
|
||||
case "PREVENTIVE":
|
||||
return t("encounters.kind.preventive");
|
||||
case "EMERGENCY":
|
||||
return t("encounters.kind.emergency");
|
||||
case "HOSPITAL":
|
||||
return t("encounters.kind.hospital");
|
||||
case "THERAPY":
|
||||
return t("encounters.kind.therapy");
|
||||
default:
|
||||
return t("encounters.kind.other");
|
||||
}
|
||||
}
|
||||
|
||||
export function encounterStatusText(
|
||||
t: Translate,
|
||||
status: EncounterStatus,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "PLANNED":
|
||||
return t("encounters.status.planned");
|
||||
case "DONE":
|
||||
return t("encounters.status.done");
|
||||
case "CANCELLED":
|
||||
return t("encounters.status.cancelled");
|
||||
default:
|
||||
return t("encounters.status.noShow");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The visit form's three optional link blocks: documents, lab results,
|
||||
* conditions.
|
||||
*
|
||||
* **The gate blanks the block, it does not post-filter it.** A module the
|
||||
* account turned off leaves nothing behind here — no heading, no empty list,
|
||||
* no "0 selected". An empty picker for a switched-off module advertises a
|
||||
* feature that is not there and invites a person to wonder what is wrong. This
|
||||
* is the same shape the dashboard snapshot uses for a disabled module.
|
||||
*
|
||||
* The encounter itself carries NO module gate. A visit is core, like the
|
||||
* checkups page it lives on; what it may point AT follows the modules that own
|
||||
* those things, which is why the gate lives here and not on the route.
|
||||
*
|
||||
* Nothing in this block can block a save. Every list starts empty and stays
|
||||
* valid empty.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** How many candidates one block offers before the person must use the sheet. */
|
||||
const OPTION_LIMIT = 25;
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
/** ISO-8601, or null for a target with no date of its own. */
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
function LinkBlock({
|
||||
icon: Icon,
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
pending,
|
||||
slot,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onToggle: (id: string) => void;
|
||||
pending: boolean;
|
||||
slot: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot={slot}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{selected.length > 0 ? (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{selected.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{pending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("encounters.form.linkNothingToOffer")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{options.map((option) => {
|
||||
const on = selected.includes(option.id);
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() => onToggle(option.id)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
on && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.date ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(option.date)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggle(list: string[], id: string): string[] {
|
||||
return list.includes(id)
|
||||
? list.filter((entry) => entry !== id)
|
||||
: [...list, id];
|
||||
}
|
||||
|
||||
interface LabListPage {
|
||||
results: Array<{
|
||||
id: string;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
takenAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DocumentListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
export function EncounterLinkPickers({
|
||||
modules,
|
||||
documentIds,
|
||||
labResultIds,
|
||||
episodeIds,
|
||||
onChange,
|
||||
}: {
|
||||
modules: Partial<Record<ModuleKey, boolean>> | undefined;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
onChange: (part: {
|
||||
documentIds?: string[];
|
||||
labResultIds?: string[];
|
||||
episodeIds?: string[];
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const documentsOn = modules?.inboundDocuments === true;
|
||||
const labsOn = modules?.labs === true;
|
||||
const illnessOn = modules?.illness === true;
|
||||
|
||||
const documents = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
|
||||
enabled: documentsOn,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
const labs = useQuery({
|
||||
queryKey: queryKeys.labResultsList({
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
}),
|
||||
enabled: labsOn,
|
||||
queryFn: () =>
|
||||
apiGet<LabListPage>(
|
||||
`/api/labs?limit=${OPTION_LIMIT}&offset=0&sortDir=desc`,
|
||||
),
|
||||
});
|
||||
|
||||
const episodes = useQuery({
|
||||
queryKey: queryKeys.illnessEpisodes(true),
|
||||
enabled: illnessOn,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=true&limit=${OPTION_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (!documentsOn && !labsOn && !illnessOn) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
|
||||
{documentsOn ? (
|
||||
<LinkBlock
|
||||
icon={FolderOpen}
|
||||
title={t("encounters.form.linkDocuments")}
|
||||
slot="encounter-link-documents"
|
||||
pending={documents.isPending}
|
||||
selected={documentIds}
|
||||
onToggle={(id) => onChange({ documentIds: toggle(documentIds, id) })}
|
||||
options={(documents.data?.documents ?? []).map((doc) => ({
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{labsOn ? (
|
||||
<LinkBlock
|
||||
icon={FlaskConical}
|
||||
title={t("encounters.form.linkLabResults")}
|
||||
slot="encounter-link-labs"
|
||||
pending={labs.isPending}
|
||||
selected={labResultIds}
|
||||
onToggle={(id) =>
|
||||
onChange({ labResultIds: toggle(labResultIds, id) })
|
||||
}
|
||||
options={(labs.data?.results ?? []).map((result) => ({
|
||||
id: result.id,
|
||||
label: result.panel
|
||||
? `${result.panel} · ${result.analyte}`
|
||||
: result.analyte,
|
||||
date: result.takenAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{illnessOn ? (
|
||||
<LinkBlock
|
||||
icon={Stethoscope}
|
||||
title={t("encounters.form.linkConditions")}
|
||||
slot="encounter-link-conditions"
|
||||
pending={episodes.isPending}
|
||||
selected={episodeIds}
|
||||
onToggle={(id) => onChange({ episodeIds: toggle(episodeIds, id) })}
|
||||
options={(episodes.data ?? []).map((episode) => ({
|
||||
id: episode.id,
|
||||
label: episode.label,
|
||||
date: episode.onsetAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The sheet the visit form lives in — create and edit, one surface.
|
||||
*
|
||||
* Save is disabled only when the date cannot be parsed. Nothing else on the
|
||||
* form can block it, which is the property the whole feature is checked
|
||||
* against.
|
||||
*
|
||||
* Two entry points pre-fill it and neither changes what is required:
|
||||
*
|
||||
* - `reminderId` — opened from a due checkup. Filing the visit as DONE closes
|
||||
* that checkup through the server's forward-only, idempotent path, so a
|
||||
* person who both ticks the box and files a visit gets one satisfaction and
|
||||
* no error.
|
||||
* - `episodeId` — opened from a condition's "Visits about this" card, with
|
||||
* the episode already linked.
|
||||
*
|
||||
* A delegate who may file the visit but not close the checkup gets the visit
|
||||
* saved and a plain sentence saying the checkup stayed open. That is reported
|
||||
* rather than skipped in silence: somebody who believes a checkup was closed
|
||||
* will not look at it again.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
|
||||
import {
|
||||
EncounterForm,
|
||||
draftFromEncounter,
|
||||
draftInstant,
|
||||
draftToBody,
|
||||
emptyDraft,
|
||||
nowLocalValue,
|
||||
type EncounterDraft,
|
||||
} from "./encounter-form";
|
||||
|
||||
export function EncounterSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
encounter,
|
||||
reminderId,
|
||||
episodeId,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** `null` creates a visit; a row edits it. */
|
||||
encounter: Encounter | null;
|
||||
/** The due checkup this visit is being filed against, when there is one. */
|
||||
reminderId?: string | null;
|
||||
/** A condition to pre-link, when the sheet was opened from an episode. */
|
||||
episodeId?: string | null;
|
||||
onSaved?: (saved: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update, remove } = useEncounterMutations();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
// Seeded once, at mount. The caller remounts this component on every open by
|
||||
// varying its `key`, so a second edit never shows the first one's values and
|
||||
// a create after an edit starts empty — without an effect that copies props
|
||||
// into state and briefly renders the stale pair.
|
||||
const [draft, setDraft] = useState<EncounterDraft>(() =>
|
||||
encounter
|
||||
? draftFromEncounter(encounter)
|
||||
: emptyDraft({
|
||||
occurredAt: nowLocalValue(),
|
||||
episodeIds: episodeId ? [episodeId] : [],
|
||||
}),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = encounter !== null;
|
||||
const pending = create.isPending || update.isPending;
|
||||
// The one gate. A date that does not parse is not a visit; everything else
|
||||
// on this form is optional by design.
|
||||
const dateUsable = draftInstant(draft.occurredAt) !== null;
|
||||
|
||||
const submit = async () => {
|
||||
const body = draftToBody(draft, { isEdit, reminderId });
|
||||
if (!body) return;
|
||||
try {
|
||||
const saved = isEdit
|
||||
? await update.mutateAsync({ id: encounter.id, body })
|
||||
: await create.mutateAsync(body);
|
||||
// The server names what its grant could not reach. Today that is only a
|
||||
// checkup closure, and a delegate seeing nothing would believe it closed
|
||||
// — so a write that did part of what was asked reports `partial`, not a
|
||||
// green tick, through the one module that owns the affordance.
|
||||
const wroteEverything = (saved.skipped?.links ?? 0) === 0;
|
||||
toastWrittenOutcome(
|
||||
wroteEverything ? "success" : "partial",
|
||||
wroteEverything
|
||||
? t(isEdit ? "encounters.updated" : "encounters.created")
|
||||
: t("encounters.checkupNotClosed"),
|
||||
);
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("encounters.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(isEdit ? "encounters.editTitle" : "encounters.createTitle")}
|
||||
description={t("encounters.formDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-save"
|
||||
disabled={!dateUsable || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<EncounterForm draft={draft} onChange={setDraft} isEdit={isEdit} />
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isEdit ? (
|
||||
<AlertDialog
|
||||
open={confirmingDelete}
|
||||
onOpenChange={setConfirmingDelete}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-delete"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("encounters.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
{/* Says what goes with it. Deleting a booked visit retires its
|
||||
reminder, and a person who booked one deserves to know the
|
||||
nudge stops rather than discover it by silence. */}
|
||||
<AlertDialogDescription>
|
||||
{t("encounters.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
remove.mutate(encounter.id, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: () => toast.error(t("encounters.deleteFailed")),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Gehört zu Termin …" — the one rendering of the suggestion, for all three
|
||||
* moments that ask it.
|
||||
*
|
||||
* The RULE is not here. It lives in `src/lib/encounters/suggest-window.ts` and
|
||||
* is resolved server-side, so a document arriving in the vault, a lab panel
|
||||
* committed from an extraction and a panel typed by hand cannot answer the same
|
||||
* question differently. What this component owns is only how the three verdicts
|
||||
* look, and it owns that once:
|
||||
*
|
||||
* one candidate → pre-selected, visibly, with one undo;
|
||||
* two or more → a picker with NOTHING pre-selected;
|
||||
* none → nothing at all. No empty picker, no "create a visit?" nudge.
|
||||
*
|
||||
* The middle branch is the point. Pre-selecting the first of two is the failure
|
||||
* that teaches a person to stop reading suggestions, and the component cannot
|
||||
* do it: the "many" verdict carries no pre-selection and this file never picks
|
||||
* one.
|
||||
*
|
||||
* It never blocks a save — every caller treats the value as optional — and it
|
||||
* is never behind an AI call: the match is a date-window query over the
|
||||
* person's own rows and resolves on an account with no provider configured.
|
||||
*
|
||||
* Controlled. The caller owns where the chosen id goes: straight into a PATCH
|
||||
* on a document, or into the body of a lab write that has not happened yet.
|
||||
*/
|
||||
import { CalendarClock, Check, Undo2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounterSuggestion } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import type { EncounterSuggestion } from "@/lib/encounters/suggest-window";
|
||||
import { encounterKindText } from "./encounter-labels";
|
||||
|
||||
export function EncounterSuggestionField({
|
||||
anchor,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
slot = "encounter-suggestion",
|
||||
}: {
|
||||
/** The date to resolve around, ISO-8601. `null` means there is nothing to ask. */
|
||||
anchor: string | null;
|
||||
/** The visit currently chosen here, or null. */
|
||||
value: string | null;
|
||||
onChange: (encounterId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** `data-slot` prefix, so a caller's tests can address its own instance. */
|
||||
slot?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const suggestion = useEncounterSuggestion(anchor, !disabled);
|
||||
|
||||
if (disabled) return null;
|
||||
const result = suggestion.data;
|
||||
if (!result || result.kind === "none") return null;
|
||||
|
||||
const label = (candidate: EncounterSuggestion) =>
|
||||
`${
|
||||
candidate.practitionerName ??
|
||||
encounterKindText(t, candidate.kind as EncounterKind)
|
||||
} · ${format.date(candidate.occurredAt)}`;
|
||||
|
||||
const candidates =
|
||||
result.kind === "one" ? [result.encounter] : result.encounters;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
data-slot={slot}
|
||||
data-verdict={result.kind}
|
||||
// The count is on the element so a test can prove the rule reached the
|
||||
// surface rather than inferring it from how many buttons rendered.
|
||||
data-candidates={candidates.length}
|
||||
>
|
||||
{/* A content sentence, not meta (§3): it is the offer itself. */}
|
||||
<p className="text-foreground flex items-center gap-2 text-sm">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
{t(
|
||||
result.kind === "one"
|
||||
? "encounters.suggestion.single"
|
||||
: "encounters.suggestion.choose",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{candidates.map((candidate) => {
|
||||
const chosen = value === candidate.id;
|
||||
return (
|
||||
<Button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
variant={chosen ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
aria-pressed={chosen}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() => onChange(chosen ? null : candidate.id)}
|
||||
>
|
||||
{result.kind === "one" ? (
|
||||
<Check
|
||||
className={cn("size-4", chosen ? "opacity-100" : "opacity-0")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{label(candidate)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{value !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot={`${slot}-undo`}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<Undo2 className="size-4" aria-hidden />
|
||||
{t("common.undo")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The practice picker on the visit form.
|
||||
*
|
||||
* A combobox rather than a select, because the address book grows past the
|
||||
* length a dropdown reads well at and a person types the first three letters
|
||||
* of a practice name faster than they scroll to it. The search runs in SQL over
|
||||
* the two plaintext columns — that is why they are plaintext.
|
||||
*
|
||||
* "Add new" opens a nested sheet rather than navigating away. A person filling
|
||||
* a half-finished visit form who is sent to another page to add a doctor comes
|
||||
* back to an empty form, so the affordance that looks helpful is the one that
|
||||
* loses their work.
|
||||
*
|
||||
* The field is OPTIONAL and says so: a person can log "the emergency room on
|
||||
* the 3rd" before they know whose name was on the door.
|
||||
*/
|
||||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { usePractitioners, type Practitioner } from "@/hooks/use-practitioners";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PractitionerSheet } from "@/components/practitioners/practitioner-sheet";
|
||||
|
||||
export function PractitionerCombobox({
|
||||
value,
|
||||
onChange,
|
||||
/** The already-resolved entry, so the closed trigger names it without a read. */
|
||||
selected,
|
||||
disabled,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (next: Practitioner | null) => void;
|
||||
selected: Practitioner | null;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
// Bumped on each open so the nested sheet remounts empty rather than showing
|
||||
// whatever the last attempt left in it.
|
||||
const [session, setSession] = useState(0);
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
|
||||
const list = usePractitioners(debounced, open);
|
||||
const rows = list.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
data-slot="encounter-practitioner-trigger"
|
||||
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
selected ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selected?.name ?? t("encounters.form.practitionerNone")}
|
||||
</span>
|
||||
<ChevronsUpDown
|
||||
className="size-4 shrink-0 opacity-50"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
|
||||
<Input
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={t("encounters.form.practitionerSearch")}
|
||||
aria-label={t("encounters.form.practitionerSearch")}
|
||||
data-slot="encounter-practitioner-search"
|
||||
/>
|
||||
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{list.isPending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-3 text-sm">
|
||||
{t("encounters.form.practitionerNoMatch")}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
data-slot="encounter-practitioner-option"
|
||||
onClick={() => {
|
||||
onChange(row);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
row.id === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate">
|
||||
{row.name}
|
||||
</span>
|
||||
{row.practice ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{row.practice}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 min-h-11 w-full"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setAdding(true);
|
||||
setSession((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.form.practitionerAdd")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selected ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0"
|
||||
aria-label={t("encounters.form.practitionerClear")}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Nested, so the half-filled visit form behind it stays mounted. */}
|
||||
<PractitionerSheet
|
||||
key={session}
|
||||
open={adding}
|
||||
onOpenChange={setAdding}
|
||||
practitioner={null}
|
||||
onSaved={(created) => onChange(created)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One visit, as a row in the list.
|
||||
*
|
||||
* The date, the practice and the reason are what the person wrote, so all three
|
||||
* are `text-foreground`; the counts and the relative distance are meta and
|
||||
* muted. Status is carried by the SECTION rather than repeated on every card —
|
||||
* a card in the upcoming list is planned by construction, and a badge saying so
|
||||
* on every row is noise that hides the two rows where the status is a surprise.
|
||||
* A cancelled or missed visit is the exception and says so, because those are
|
||||
* the ones a person is scanning for.
|
||||
*/
|
||||
import {
|
||||
CalendarClock,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { Encounter } from "@/hooks/use-encounters";
|
||||
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
||||
import { encounterKindText, encounterStatusText } from "./encounter-labels";
|
||||
|
||||
/** The two statuses worth calling out on a row; the rest the section implies. */
|
||||
function isNoteworthy(status: EncounterStatus): boolean {
|
||||
return status === "CANCELLED" || status === "NO_SHOW";
|
||||
}
|
||||
|
||||
export function VisitCard({
|
||||
encounter,
|
||||
onOpen,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
onOpen: (encounter: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
const counts = [
|
||||
{
|
||||
icon: FileText,
|
||||
n: encounter.links?.documents.length ?? 0,
|
||||
label: t("encounters.card.documentCount"),
|
||||
},
|
||||
{
|
||||
icon: FlaskConical,
|
||||
n: encounter.links?.labResults.length ?? 0,
|
||||
label: t("encounters.card.labCount"),
|
||||
},
|
||||
{
|
||||
icon: Stethoscope,
|
||||
n: encounter.links?.conditions.length ?? 0,
|
||||
label: t("encounters.card.conditionCount"),
|
||||
},
|
||||
].filter((entry) => entry.n > 0);
|
||||
|
||||
// The practice, or the kind when there is none. A visit with neither is not
|
||||
// nameless — the kind always resolves, because it defaults to one.
|
||||
const heading =
|
||||
encounter.practitioner?.name ??
|
||||
encounterKindText(t, encounter.kind as EncounterKind);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="visit-card"
|
||||
data-encounter-id={encounter.id}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(encounter)}
|
||||
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{heading}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{format.dateTime(encounter.occurredAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{encounterKindText(t, encounter.kind as EncounterKind)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{encounter.reason ? (
|
||||
<p className="text-foreground line-clamp-1 text-sm">
|
||||
{encounter.reason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ||
|
||||
counts.length ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{encounterStatusText(t, encounter.status as EncounterStatus)}
|
||||
</span>
|
||||
) : null}
|
||||
{counts.map(({ icon: Icon, n, label }) => (
|
||||
<span
|
||||
key={label}
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs tabular-nums"
|
||||
aria-label={`${n} ${label}`}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Termine" — the visits half of the checkups page.
|
||||
*
|
||||
* Two lists rather than one, and the split is the server's: upcoming reads
|
||||
* soonest-first, past reads newest-first, and the API resolves both so the
|
||||
* ordering lives in one place. A client that split and re-sorted one array
|
||||
* would be the second place it lives.
|
||||
*
|
||||
* No new nav entry and no new route. `/checkups` already owns exactly the right
|
||||
* sentence — when do I have to do what, where — and is deliberately not module
|
||||
* gated, which is also why a visit carries no module key of its own.
|
||||
*/
|
||||
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EncounterSheet } from "./encounter-sheet";
|
||||
import { VisitCard } from "./visit-card";
|
||||
|
||||
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const list = useEncounters(enabled);
|
||||
|
||||
// `null` closed, `"new"` create, a row edits it.
|
||||
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
|
||||
const [session, setSession] = useState(0);
|
||||
const openSheet = (target: Encounter | "new") => {
|
||||
setEditing(target);
|
||||
setSession((n) => n + 1);
|
||||
};
|
||||
|
||||
const upcoming = list.data?.upcoming ?? [];
|
||||
const past = list.data?.past ?? [];
|
||||
const empty = upcoming.length === 0 && past.length === 0;
|
||||
|
||||
const addButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="visits-add"
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="visits-section-title" className="space-y-4">
|
||||
<PageHeader
|
||||
titleId="visits-section-title"
|
||||
title={t("encounters.sectionTitle")}
|
||||
description={t("encounters.sectionDescription")}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/checkups/practitioners">
|
||||
<ContactRound className="size-4" aria-hidden />
|
||||
{t("practitioners.linkLabel")}
|
||||
</Link>
|
||||
</Button>
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : empty ? (
|
||||
<EmptyState
|
||||
icon={<CalendarClock className="size-6" aria-hidden />}
|
||||
title={t("encounters.emptyTitle")}
|
||||
description={t("encounters.emptyDescription")}
|
||||
action={addButton ?? undefined}
|
||||
ctaSize="lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{upcoming.length > 0 ? (
|
||||
<div className="space-y-2" data-slot="visits-upcoming">
|
||||
<SectionHeading
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.upcomingHeading")}
|
||||
/>
|
||||
{upcoming.map((encounter) => (
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{past.length > 0 ? (
|
||||
<div className="space-y-2" data-slot="visits-past">
|
||||
<SectionHeading
|
||||
icon={History}
|
||||
title={t("encounters.pastHeading")}
|
||||
/>
|
||||
{past.map((encounter) => (
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* `key` changes on every open, so the sheet remounts with a fresh draft
|
||||
instead of copying props into state from an effect. It stays mounted
|
||||
while closing, which is what keeps the exit animation. */}
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
encounter={editing === "new" ? null : editing}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Termine dazu" — the visits filed against one condition episode.
|
||||
*
|
||||
* **From the episode side only, and that is a decision rather than an
|
||||
* omission.** An episode spans weeks and a visit is a point, so matching from
|
||||
* the VISIT side would produce false positives at a rate that teaches a person
|
||||
* to ignore every suggestion in the product. Here the person already has the
|
||||
* condition in front of them and is the one who knows; the card offers an
|
||||
* action instead of a guess.
|
||||
*
|
||||
* Modelled on the episode's documents card, which does exactly this for the
|
||||
* vault. Renders nothing when the illness module is off — a gated surface never
|
||||
* leaks, and an episode page is unreachable in that state anyway.
|
||||
*/
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [session, setSession] = useState(0);
|
||||
|
||||
const moduleEnabled = user?.modules?.illness === true;
|
||||
// The filter runs in SQL: the two arms of the response are separately
|
||||
// bounded, so narrowing here would page wrongly on a long history.
|
||||
const list = useEncounters(moduleEnabled, { episodeId });
|
||||
|
||||
if (!moduleEnabled) return null;
|
||||
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visits = [...(list.data?.upcoming ?? []), ...(list.data?.past ?? [])];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card data-slot="episode-visits-card">
|
||||
<CardContent className="space-y-4">
|
||||
<TileHeader
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.episodeCard.title")}
|
||||
right={
|
||||
visits.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{visits.length}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : visits.length === 0 ? (
|
||||
// A quiet one-line absence, not a teaching empty state: the person
|
||||
// came here about the condition, not to be taught about visits.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("encounters.episodeCard.emptyLine")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{visits.map((visit) => (
|
||||
<li key={visit.id}>
|
||||
<ListRow asChild>
|
||||
<Link
|
||||
href="/checkups"
|
||||
data-encounter-id={visit.id}
|
||||
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<CalendarClock
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(visit.occurredAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</ListRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="episode-visits-add"
|
||||
onClick={() => {
|
||||
setSheetOpen(true);
|
||||
setSession((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.episodeCard.add")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
encounter={null}
|
||||
// Pre-linked, so the person never has to find this condition again
|
||||
// in a picker they already navigated away from.
|
||||
episodeId={episodeId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
|
||||
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -156,6 +157,12 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
nothing when the documents module is off for this account. */}
|
||||
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{/* Condition ⇄ visits linking, condition side ONLY. A visit never
|
||||
suggests an episode: an episode spans weeks and a visit is a point,
|
||||
so the reverse match would be wrong often enough to train the person
|
||||
to ignore every suggestion in the product. */}
|
||||
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{episode ? (
|
||||
<>
|
||||
<LogDaySheet
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* `<EcgDetail>` — one recording, addressed by id.
|
||||
*
|
||||
* The non-diagnostic framing is the load-bearing part and it is unchanged by
|
||||
* the move to a route: the result shown is the DEVICE's, attributed to the
|
||||
* device, and a non-normal device result adds the "discuss with a clinician"
|
||||
* note while a normal one does not.
|
||||
*
|
||||
* What did change: the component reads by id rather than being handed a row
|
||||
* from the list, so a cold deep link works — the positive control below is
|
||||
* that a render with only an id in hand still paints the trace.
|
||||
*/
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: unknown) => useQueryMock(opts),
|
||||
}));
|
||||
|
||||
const { EcgDetail } = await import("../ecg-detail");
|
||||
type EcgDetailResponse = import("../ecg-detail").EcgDetailResponse;
|
||||
|
||||
const DETAIL: EcgDetailResponse = {
|
||||
recordedAt: "2026-06-01T09:15:00.000Z",
|
||||
durationSeconds: 30,
|
||||
samplingFrequency: 300,
|
||||
averageHeartRate: 72,
|
||||
lead: null,
|
||||
classification: "IRREGULAR",
|
||||
source: "WITHINGS",
|
||||
samples: [0, 10, -5, 40, -20, 5, 0],
|
||||
decimated: true,
|
||||
};
|
||||
|
||||
function renderDetail(detail: Partial<EcgDetailResponse> = {}) {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { ...DETAIL, ...detail },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail recordingId="ecg_1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<EcgDetail> — non-diagnostic framing", () => {
|
||||
it("attributes the result to the recording device and draws the waveform", () => {
|
||||
const html = renderDetail();
|
||||
expect(html).toContain('data-slot="ecg-detail"');
|
||||
expect(html).toContain('data-slot="ecg-result"');
|
||||
// Load-bearing: the verdict is the device's, attributed to the device.
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
expect(html).toContain("Atrial fibrillation detected");
|
||||
expect(html).toContain('data-slot="ecg-trace"');
|
||||
});
|
||||
|
||||
it("adds the 'discuss with a clinician' note on a non-normal result", () => {
|
||||
const html = renderDetail();
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
expect(html).toContain("discuss this recording with a clinician");
|
||||
});
|
||||
|
||||
it("adds the clinician note for an INCONCLUSIVE result", () => {
|
||||
const html = renderDetail({ classification: "INCONCLUSIVE" });
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
});
|
||||
|
||||
it("does NOT add the clinician note for a normal (not-detected) result", () => {
|
||||
const html = renderDetail({ classification: "NOT_DETECTED" });
|
||||
expect(html).not.toContain('data-slot="ecg-clinician-note"');
|
||||
// The device attribution is still there.
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
});
|
||||
|
||||
it("says a failed read failed instead of painting an empty recording", () => {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail recordingId="ecg_1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain('data-slot="ecg-detail-error"');
|
||||
expect(html).not.toContain('data-slot="ecg-trace"');
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,19 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.28.50 — `<EcgSection>` / `<EcgDetail>` unit tests.
|
||||
* `<EcgSection>` — the list of recorded strips.
|
||||
*
|
||||
* The load-bearing behaviour under test:
|
||||
* - data-availability gating: the section un-mounts entirely when the
|
||||
* user has no recordings or while the payload is in flight;
|
||||
* - the NON-DIAGNOSTIC framing (mirrors `RhythmEventsCard`): the device's
|
||||
* result is shown attributed to the device, a permanent disclaimer
|
||||
* states HealthLog does not interpret / diagnose, and any non-normal
|
||||
* device result adds the "discuss with a clinician" note — while a
|
||||
* normal result does NOT.
|
||||
* - data-availability gating: the section un-mounts entirely when the user
|
||||
* has no recordings or while the payload is in flight;
|
||||
* - the reading cap: a device that records every morning accumulates
|
||||
* hundreds of strips, and the list paints the most recent handful;
|
||||
* - the device's verdict rides the timestamp as a tag, and a row that has
|
||||
* a waveform opens the recording's own address.
|
||||
*
|
||||
* `useAuth` + TanStack Query are mocked and the assertions run through SSR
|
||||
* (the suite's node environment has no DOM), exactly like the sibling
|
||||
* `rhythm-events-card` test.
|
||||
* `rhythm-events-card` test. `<EcgDetail>` has its own spec.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -29,20 +28,8 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: unknown) => useQueryMock(opts),
|
||||
}));
|
||||
|
||||
const { EcgSection, EcgDetail } = await import("../ecg-section");
|
||||
|
||||
interface EcgRecordingListItem {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
samplingFrequency: number;
|
||||
sampleCount: number;
|
||||
averageHeartRate: number | null;
|
||||
lead: string | null;
|
||||
classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
|
||||
source: string;
|
||||
hasWaveform: boolean;
|
||||
}
|
||||
const { EcgSection, ECG_OVERVIEW_LIMIT } = await import("../ecg-section");
|
||||
type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem;
|
||||
|
||||
const IRREGULAR_REC: EcgRecordingListItem = {
|
||||
id: "ecg_1",
|
||||
@@ -63,44 +50,21 @@ const NORMAL_REC: EcgRecordingListItem = {
|
||||
classification: "NOT_DETECTED",
|
||||
};
|
||||
|
||||
const DETAIL = {
|
||||
recordedAt: "2026-06-01T09:15:00.000Z",
|
||||
durationSeconds: 30,
|
||||
samplingFrequency: 300,
|
||||
averageHeartRate: 72,
|
||||
lead: null,
|
||||
classification: "IRREGULAR" as const,
|
||||
source: "WITHINGS",
|
||||
samples: [0, 10, -5, 40, -20, 5, 0],
|
||||
decimated: true,
|
||||
};
|
||||
|
||||
function renderSection(
|
||||
data:
|
||||
{ recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined,
|
||||
props: { limit?: number } = {},
|
||||
) {
|
||||
useQueryMock.mockReturnValue({ data, isLoading: false });
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgSection />
|
||||
<EcgSection {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderDetail(
|
||||
recording: EcgRecordingListItem,
|
||||
resultLabel: string | null,
|
||||
) {
|
||||
useQueryMock.mockReturnValue({ data: DETAIL, isLoading: false });
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail
|
||||
recording={recording}
|
||||
resultLabel={resultLabel}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
function countRows(html: string): number {
|
||||
return (html.match(/data-slot="ecg-row"/g) ?? []).length;
|
||||
}
|
||||
|
||||
describe("<EcgSection>", () => {
|
||||
@@ -119,65 +83,95 @@ describe("<EcgSection>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the recording list with a device-result label per row", () => {
|
||||
it("renders one row per recording with the device result as a tag", () => {
|
||||
const html = renderSection({
|
||||
recordings: [IRREGULAR_REC, NORMAL_REC],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="ecg-card"');
|
||||
expect(html).toContain('data-slot="ecg-list"');
|
||||
const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length;
|
||||
expect(rows).toBe(2);
|
||||
expect(countRows(html)).toBe(2);
|
||||
expect(html).toContain("Atrial fibrillation detected");
|
||||
expect(html).toContain("No signs of atrial fibrillation");
|
||||
// The verdict sits beside the timestamp inside the row's own header
|
||||
// line, not on a line of its own beneath it.
|
||||
expect(html).toMatch(
|
||||
/<span[^>]*data-slot="ecg-row-result"[^>]*class="[^"]*rounded-full/,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the permanent non-diagnostic disclaimer on the list", () => {
|
||||
const eightRecordings = () =>
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
...NORMAL_REC,
|
||||
id: `ecg_${index}`,
|
||||
recordedAt: new Date(Date.UTC(2026, 5, 20 - index, 9, 15)).toISOString(),
|
||||
}));
|
||||
|
||||
it("caps the OVERVIEW teaser at the most recent handful", () => {
|
||||
// The teaser passes `limit`. Eight strips in, five out — and the five are
|
||||
// the ones the route handed back first, which it orders `recordedAt desc`.
|
||||
const recordings = eightRecordings();
|
||||
const html = renderSection(
|
||||
{ recordings, hasRecordings: true },
|
||||
{ limit: ECG_OVERVIEW_LIMIT },
|
||||
);
|
||||
|
||||
expect(ECG_OVERVIEW_LIMIT).toBe(5);
|
||||
expect(countRows(html)).toBe(ECG_OVERVIEW_LIMIT);
|
||||
for (const kept of recordings.slice(0, ECG_OVERVIEW_LIMIT)) {
|
||||
expect(html, `${kept.id} should be listed`).toContain(
|
||||
`/insights/ecg/${kept.id}`,
|
||||
);
|
||||
}
|
||||
for (const dropped of recordings.slice(ECG_OVERVIEW_LIMIT)) {
|
||||
expect(html, `${dropped.id} is past the teaser cap`).not.toContain(
|
||||
`/insights/ecg/${dropped.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders every recording when NO limit is given (the full page)", () => {
|
||||
// The dedicated `/insights/ecg` page passes no `limit`, so an account with
|
||||
// many strips reaches all of them — the cap is a teaser affordance, never
|
||||
// a wall on the full surface. Eight seeded, eight rendered, every one
|
||||
// reachable by its own address.
|
||||
const recordings = eightRecordings();
|
||||
const html = renderSection({ recordings, hasRecordings: true });
|
||||
|
||||
expect(countRows(html)).toBe(8);
|
||||
for (const rec of recordings) {
|
||||
expect(html, `${rec.id} must be reachable on the full page`).toContain(
|
||||
`/insights/ecg/${rec.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the recording's own address when it has a waveform", () => {
|
||||
const html = renderSection({
|
||||
recordings: [IRREGULAR_REC],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).toContain(
|
||||
"HealthLog does not read or interpret ECG recordings",
|
||||
);
|
||||
expect(html).toContain("does not provide a diagnosis");
|
||||
expect(html).toContain('href="/insights/ecg/ecg_1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<EcgDetail> — non-diagnostic framing", () => {
|
||||
it("attributes the result to the recording device and draws the waveform", () => {
|
||||
const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected");
|
||||
expect(html).toContain('data-slot="ecg-detail"');
|
||||
expect(html).toContain('data-slot="ecg-result"');
|
||||
// Load-bearing: the verdict is the device's, attributed to the device.
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
expect(html).toContain("Atrial fibrillation detected");
|
||||
// The waveform strip is rendered.
|
||||
expect(html).toContain('data-slot="ecg-trace"');
|
||||
// And the permanent disclaimer is present here too.
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
it("leaves a waveform-less recording unlinked", () => {
|
||||
const html = renderSection({
|
||||
recordings: [{ ...IRREGULAR_REC, hasWaveform: false }],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(countRows(html)).toBe(1);
|
||||
expect(html).not.toContain('href="/insights/ecg/ecg_1"');
|
||||
});
|
||||
|
||||
it("adds the 'discuss with a clinician' note on a non-normal result", () => {
|
||||
const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected");
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
expect(html).toContain("discuss this recording with a clinician");
|
||||
});
|
||||
|
||||
it("adds the clinician note for an INCONCLUSIVE result", () => {
|
||||
const html = renderDetail(
|
||||
{ ...IRREGULAR_REC, classification: "INCONCLUSIVE" },
|
||||
"Inconclusive recording",
|
||||
);
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
});
|
||||
|
||||
it("does NOT add the clinician note for a normal (not-detected) result", () => {
|
||||
const html = renderDetail(NORMAL_REC, "No signs of atrial fibrillation");
|
||||
expect(html).not.toContain('data-slot="ecg-clinician-note"');
|
||||
// But the disclaimer + device attribution are still present.
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
it("carries no intro paragraph and no disclaimer block", () => {
|
||||
// Both were removed: the page heading already says what the surface is,
|
||||
// and the device attribution on the detail page is where the "this is
|
||||
// the device's result" statement belongs.
|
||||
const html = renderSection({
|
||||
recordings: [IRREGULAR_REC],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).not.toContain("Single-lead ECG strips");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import { InsightSectionCard } from "@/components/insights/insight-section-card";
|
||||
import {
|
||||
ecgResultLabel,
|
||||
isNonNormalEcg,
|
||||
type EcgClassification,
|
||||
} from "@/lib/insights/ecg-classification";
|
||||
|
||||
/**
|
||||
* One ECG recording: the trace, the metadata, and the DEVICE's own result.
|
||||
*
|
||||
* NON-DIAGNOSTIC: the result shown is the recording device's, attributed to
|
||||
* the device ("Recorded result: …, as reported by the recording device").
|
||||
* HealthLog reads nothing off the curve. A non-normal device result adds the
|
||||
* "discuss with a clinician" note. Plain React text children throughout.
|
||||
*
|
||||
* The component owns its own read, addressed by id, so the detail page needs
|
||||
* nothing from the list it was opened from — a deep link works cold.
|
||||
*/
|
||||
|
||||
export interface EcgDetailResponse {
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
samplingFrequency: number;
|
||||
averageHeartRate: number | null;
|
||||
lead: string | null;
|
||||
classification: EcgClassification;
|
||||
source: string;
|
||||
samples: number[];
|
||||
decimated: boolean;
|
||||
}
|
||||
|
||||
export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<EcgDetailResponse>(
|
||||
`/api/insights/ecg/${recordingId}`,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(t("insights.ecg.loadError"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
// A failed read must never read as "no data" (UI-STANDARDS §6).
|
||||
return (
|
||||
<p
|
||||
data-slot="ecg-detail-error"
|
||||
role="alert"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("insights.ecg.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div
|
||||
data-slot="ecg-waveform-skeleton"
|
||||
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const resultLabel = ecgResultLabel(data.classification, t);
|
||||
const leadLabel = data.lead ?? t("insights.ecg.meta.leadSingle");
|
||||
const durationLabel =
|
||||
data.durationSeconds != null
|
||||
? t("insights.ecg.meta.durationValue", {
|
||||
seconds: Math.round(data.durationSeconds),
|
||||
})
|
||||
: t("insights.ecg.meta.unknown");
|
||||
|
||||
return (
|
||||
<InsightSectionCard slot="ecg-detail" className="border-border">
|
||||
{/* The DEVICE's result, attributed to the device. This is the ONLY
|
||||
verdict shown — HealthLog produces none. */}
|
||||
{resultLabel && (
|
||||
<p
|
||||
data-slot="ecg-result"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.resultAttribution", { result: resultLabel })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<EcgWaveform
|
||||
samples={data.samples}
|
||||
recordedAt={data.recordedAt}
|
||||
durationSeconds={data.durationSeconds}
|
||||
averageHeartRate={data.averageHeartRate}
|
||||
resultLabel={resultLabel}
|
||||
/>
|
||||
|
||||
<dl
|
||||
data-slot="ecg-meta"
|
||||
className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"
|
||||
>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.recorded")}
|
||||
value={fmt.dateTime(new Date(data.recordedAt))}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.duration")}
|
||||
value={durationLabel}
|
||||
/>
|
||||
<MetaItem label={t("insights.ecg.meta.lead")} value={leadLabel} />
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.averageHeartRate")}
|
||||
value={
|
||||
data.averageHeartRate != null
|
||||
? t("insights.ecg.meta.bpmValue", { bpm: data.averageHeartRate })
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.samplingRate")}
|
||||
value={
|
||||
data.samplingFrequency > 0
|
||||
? t("insights.ecg.meta.hzValue", { hz: data.samplingFrequency })
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{/* "Discuss with a clinician" — only on a non-normal device result. */}
|
||||
{isNonNormalEcg(data.classification) && (
|
||||
<p
|
||||
data-slot="ecg-clinician-note"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.clinicianNote")}
|
||||
</p>
|
||||
)}
|
||||
</InsightSectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, ChevronLeft, HeartPulse, Info } from "lucide-react";
|
||||
import { Activity, ChevronRight, HeartPulse } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -10,35 +10,41 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import {
|
||||
ecgResultLabel,
|
||||
ECG_OVERVIEW_LIMIT,
|
||||
type EcgClassification,
|
||||
} from "@/lib/insights/ecg-classification";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
// Re-exported so the tests (and any teaser call site) can keep importing the
|
||||
// overview cap from the component they are exercising; the value itself lives
|
||||
// in the React-free lib module.
|
||||
export { ECG_OVERVIEW_LIMIT };
|
||||
|
||||
/**
|
||||
* v1.28.50 — ECG recording surface (list → detail).
|
||||
* v1.28.50 — ECG recording list.
|
||||
*
|
||||
* Renders the user's ECG recordings synced from a single-lead device
|
||||
* (Withings ScanWatch today): a list of strips, each opening a detail view
|
||||
* with the `EcgWaveform` trace, the recording metadata, and the DEVICE's
|
||||
* own classification.
|
||||
* The user's ECG recordings synced from a single-lead device (Withings
|
||||
* ScanWatch today): one row per strip, each opening `/insights/ecg/<id>`
|
||||
* with the trace, the metadata and the DEVICE's own classification.
|
||||
*
|
||||
* NON-DIAGNOSTIC (load-bearing, do not soften — mirrors `RhythmEventsCard`):
|
||||
* the surface shows only the waveform (raw data), the metadata, and the
|
||||
* recording device's own result, attributed unambiguously to the device
|
||||
* ("Recorded result: …, as reported by the recording device"). HealthLog
|
||||
* generates NO interpretation of the waveform — no measured intervals, no
|
||||
* beat/P/QRS/T annotation, no risk score, no verdict of its own. A
|
||||
* permanent, non-dismissible disclaimer states this, and any non-normal
|
||||
* device result additionally carries a "discuss with a clinician" note.
|
||||
* All copy renders as plain React text children (no markdown library — the
|
||||
* standing XSS rule).
|
||||
* NON-DIAGNOSTIC: the surface shows the waveform (raw data), the metadata
|
||||
* and the recording device's own result, attributed to the device on the
|
||||
* detail page. HealthLog generates NO interpretation of the waveform — no
|
||||
* measured intervals, no beat/P/QRS/T annotation, no risk score, no verdict
|
||||
* of its own. All copy renders as plain React text children (no markdown
|
||||
* library — the standing XSS rule).
|
||||
*
|
||||
* The list is capped: a device that records every morning accumulates
|
||||
* hundreds of strips, and a page that paints all of them is a scroll, not a
|
||||
* history. The most recent handful is what the surface is for.
|
||||
*
|
||||
* Data-availability-gated: the section un-mounts entirely (`return null`)
|
||||
* when the user has no recordings — never an empty / alarming card.
|
||||
*/
|
||||
|
||||
type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
|
||||
|
||||
interface EcgRecordingListItem {
|
||||
export interface EcgRecordingListItem {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
@@ -56,30 +62,6 @@ interface EcgListResponse {
|
||||
hasRecordings: boolean;
|
||||
}
|
||||
|
||||
interface EcgDetailResponse {
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
samplingFrequency: number;
|
||||
averageHeartRate: number | null;
|
||||
lead: string | null;
|
||||
classification: EcgClassification;
|
||||
source: string;
|
||||
samples: number[];
|
||||
decimated: boolean;
|
||||
}
|
||||
|
||||
/** The device verdict, surfaced verbatim and attributed to the device. */
|
||||
const RESULT_LABEL_KEYS: Record<string, string> = {
|
||||
IRREGULAR: "insights.ecg.result.irregular",
|
||||
NOT_DETECTED: "insights.ecg.result.notDetected",
|
||||
INCONCLUSIVE: "insights.ecg.result.inconclusive",
|
||||
};
|
||||
|
||||
/** Non-normal device results trigger the "discuss with a clinician" note. */
|
||||
function isNonNormal(classification: EcgClassification): boolean {
|
||||
return classification === "IRREGULAR" || classification === "INCONCLUSIVE";
|
||||
}
|
||||
|
||||
interface EcgSectionProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
@@ -87,21 +69,27 @@ interface EcgSectionProps {
|
||||
* v1.30 — suppress the internal `<SectionHeading>` when the section is
|
||||
* hosted on the routed `/insights/ecg` sub-page, whose `<SubPageShell>`
|
||||
* already renders the page `<h1>`. Defaults to `false` so the overview
|
||||
* teaser keeps its own heading unchanged. Purely presentational — the
|
||||
* non-diagnostic disclaimer + waveform logic are untouched.
|
||||
* teaser keeps its own heading unchanged.
|
||||
*/
|
||||
hideHeading?: boolean;
|
||||
/**
|
||||
* Cap the number of strips shown. The overview teaser passes
|
||||
* `ECG_OVERVIEW_LIMIT`; the dedicated `/insights/ecg` page passes nothing,
|
||||
* so it renders the full list. Omitted = no cap: a full surface must never
|
||||
* silently hide a recording behind a limit with no way to reach it.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function EcgSection({
|
||||
enabled = true,
|
||||
className,
|
||||
hideHeading = false,
|
||||
limit,
|
||||
}: EcgSectionProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgList(),
|
||||
@@ -118,16 +106,11 @@ export function EcgSection({
|
||||
// Data-availability gate — never paint an empty card.
|
||||
if (isLoading || !data || !data.hasRecordings) return null;
|
||||
|
||||
const selected =
|
||||
selectedId != null
|
||||
? (data.recordings.find((r) => r.id === selectedId) ?? null)
|
||||
: null;
|
||||
|
||||
function resultLabel(classification: EcgClassification): string | null {
|
||||
if (!classification) return null;
|
||||
const key = RESULT_LABEL_KEYS[classification];
|
||||
return key ? t(key) : null;
|
||||
}
|
||||
// The route hands them back most-recent-first. A `limit` (the overview
|
||||
// teaser) keeps the newest few; no limit (the dedicated page) shows them
|
||||
// all, so an older strip is never made unreachable.
|
||||
const recordings =
|
||||
limit != null ? data.recordings.slice(0, limit) : data.recordings;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -143,230 +126,67 @@ export function EcgSection({
|
||||
/>
|
||||
)}
|
||||
<InsightSectionCard slot="ecg-card" className="border-border">
|
||||
{selected ? (
|
||||
<EcgDetail
|
||||
recording={selected}
|
||||
resultLabel={resultLabel(selected.classification)}
|
||||
onBack={() => setSelectedId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.ecg.sectionIntro")}
|
||||
</p>
|
||||
<ol data-slot="ecg-list" className="space-y-3">
|
||||
{data.recordings.map((rec) => {
|
||||
const label = resultLabel(rec.classification);
|
||||
const rowInner = (
|
||||
<>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<HeartPulse className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fmt.dateTime(new Date(rec.recordedAt))}
|
||||
</p>
|
||||
{label && (
|
||||
<p
|
||||
data-slot="ecg-row-result"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
{rec.averageHeartRate != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.ecg.meta.bpmValue", {
|
||||
bpm: rec.averageHeartRate,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={rec.id}
|
||||
data-slot="ecg-row"
|
||||
data-classification={rec.classification ?? "NONE"}
|
||||
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{rec.hasWaveform ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(rec.id)}
|
||||
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
|
||||
<ol data-slot="ecg-list" className="space-y-3">
|
||||
{recordings.map((rec) => {
|
||||
const label = ecgResultLabel(rec.classification, t);
|
||||
const rowInner = (
|
||||
<>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<HeartPulse className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
{/* The device's verdict rides the timestamp as a tag
|
||||
rather than claiming a line of its own — it qualifies
|
||||
the recording, it is not a second fact about it. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fmt.dateTime(new Date(rec.recordedAt))}
|
||||
</p>
|
||||
{label && (
|
||||
<span
|
||||
data-slot="ecg-row-result"
|
||||
className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-xs"
|
||||
>
|
||||
{rowInner}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">{rowInner}</div>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Load-bearing regulatory disclaimer — permanent,
|
||||
non-dismissible. Plain React text children (no markdown). */}
|
||||
<EcgDisclaimer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{rec.averageHeartRate != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.ecg.meta.bpmValue", {
|
||||
bpm: rec.averageHeartRate,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={rec.id}
|
||||
data-slot="ecg-row"
|
||||
data-classification={rec.classification ?? "NONE"}
|
||||
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{rec.hasWaveform ? (
|
||||
<Link
|
||||
href={`/insights/ecg/${rec.id}`}
|
||||
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
|
||||
>
|
||||
{rowInner}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground mt-1.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">{rowInner}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail view: waveform + metadata + device-attributed result +
|
||||
* permanent disclaimer + clinician note on a non-normal device result.
|
||||
* Exported so the non-diagnostic framing can be unit-tested directly.
|
||||
*/
|
||||
export function EcgDetail({
|
||||
recording,
|
||||
resultLabel,
|
||||
onBack,
|
||||
}: {
|
||||
recording: EcgRecordingListItem;
|
||||
resultLabel: string | null;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data: detail, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgDetail(recording.id, false),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<EcgDetailResponse>(
|
||||
`/api/insights/ecg/${recording.id}`,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(t("insights.ecg.loadError"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const nonNormal = isNonNormal(recording.classification);
|
||||
const leadLabel = recording.lead ?? t("insights.ecg.meta.leadSingle");
|
||||
const durationLabel =
|
||||
recording.durationSeconds != null
|
||||
? t("insights.ecg.meta.durationValue", {
|
||||
seconds: Math.round(recording.durationSeconds),
|
||||
})
|
||||
: t("insights.ecg.meta.unknown");
|
||||
|
||||
return (
|
||||
<div data-slot="ecg-detail" className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground hover:text-foreground -ml-1 flex items-center gap-1 text-sm transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t("insights.ecg.backToList")}
|
||||
</button>
|
||||
|
||||
{/* The DEVICE's result, attributed to the device. This is the ONLY
|
||||
verdict shown — HealthLog produces none. */}
|
||||
{resultLabel && (
|
||||
<p
|
||||
data-slot="ecg-result"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.resultAttribution", { result: resultLabel })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading || !detail ? (
|
||||
<div
|
||||
data-slot="ecg-waveform-skeleton"
|
||||
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<EcgWaveform
|
||||
samples={detail.samples}
|
||||
recordedAt={detail.recordedAt}
|
||||
durationSeconds={detail.durationSeconds}
|
||||
averageHeartRate={detail.averageHeartRate}
|
||||
resultLabel={resultLabel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<dl
|
||||
data-slot="ecg-meta"
|
||||
className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"
|
||||
>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.recorded")}
|
||||
value={fmt.dateTime(new Date(recording.recordedAt))}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.duration")}
|
||||
value={durationLabel}
|
||||
/>
|
||||
<MetaItem label={t("insights.ecg.meta.lead")} value={leadLabel} />
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.averageHeartRate")}
|
||||
value={
|
||||
recording.averageHeartRate != null
|
||||
? t("insights.ecg.meta.bpmValue", {
|
||||
bpm: recording.averageHeartRate,
|
||||
})
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.samplingRate")}
|
||||
value={
|
||||
recording.samplingFrequency > 0
|
||||
? t("insights.ecg.meta.hzValue", {
|
||||
hz: recording.samplingFrequency,
|
||||
})
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{/* "Discuss with a clinician" — only on a non-normal device result. */}
|
||||
{nonNormal && (
|
||||
<p
|
||||
data-slot="ecg-clinician-note"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.clinicianNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<EcgDisclaimer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The permanent, non-dismissible non-diagnostic disclaimer. States that the
|
||||
* displayed result is the device's and that HealthLog does not read,
|
||||
* interpret, or diagnose ECG recordings.
|
||||
*/
|
||||
function EcgDisclaimer() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="ecg-disclaimer"
|
||||
role="note"
|
||||
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
|
||||
>
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>{t("insights.ecg.disclaimer")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* What reaches the screen, not what the strings say.
|
||||
*
|
||||
* The copy guard reads the bundles; this reads the render. Between them they
|
||||
* cover the two halves of the same rule — that the two readings of a day are
|
||||
* shown side by side and never merged, that the forecast never appears without
|
||||
* its band and its count, and that the deviation is measured against the band
|
||||
* rather than against the midpoint.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
|
||||
|
||||
import { MoodPrognosisView } from "../mood-prognosis-card";
|
||||
|
||||
function render(reading: PrognosisReading): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodPrognosisView reading={reading} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function present(over: Partial<PrognosisReading> = {}): PrognosisReading {
|
||||
return {
|
||||
present: true,
|
||||
date: "2026-08-07",
|
||||
predicted: 5.4,
|
||||
ciLow: 4.1,
|
||||
ciHigh: 6.8,
|
||||
n: 42,
|
||||
modelVersion: "mood-ridge-1",
|
||||
computedAt: "2026-08-08T02:35:00.000Z",
|
||||
ageDays: 1,
|
||||
current: true,
|
||||
provisional: false,
|
||||
stage: "regular",
|
||||
entries: 64,
|
||||
seasonalUnlocked: false,
|
||||
selfAssessment: 6,
|
||||
deviation: "within",
|
||||
deviationAmount: 0,
|
||||
contributions: [
|
||||
{ feature: "dimension:a2", contribution: 0.9 },
|
||||
{ feature: "linked:steps", contribution: -0.4 },
|
||||
],
|
||||
...over,
|
||||
} as PrognosisReading;
|
||||
}
|
||||
|
||||
describe("<MoodPrognosisView>", () => {
|
||||
it("shows both readings, and shows the person's own one first", () => {
|
||||
const html = render(present());
|
||||
expect(html).toContain('data-slot="mood-prognosis-self"');
|
||||
expect(html).toContain('data-slot="mood-prognosis-expected"');
|
||||
// The self block precedes the expected block in the document, which is
|
||||
// what "the self-assessment leads" means to a screen reader.
|
||||
expect(html.indexOf('data-slot="mood-prognosis-self"')).toBeLessThan(
|
||||
html.indexOf('data-slot="mood-prognosis-expected"'),
|
||||
);
|
||||
// Both values are on screen as themselves. A merged single figure is the
|
||||
// failure this whole feature is shaped to avoid.
|
||||
expect(html).toContain("6.0");
|
||||
expect(html).toContain("5.4");
|
||||
});
|
||||
|
||||
it("never shows the value without its band and its count", () => {
|
||||
const html = render(present());
|
||||
expect(html).toContain("4.1");
|
||||
expect(html).toContain("6.8");
|
||||
expect(html).toContain("42");
|
||||
});
|
||||
|
||||
it("phrases the forecast as a counterfactual", () => {
|
||||
const html = render(present());
|
||||
expect(html).toContain("would have been expected");
|
||||
expect(html).not.toContain("your mood is");
|
||||
});
|
||||
|
||||
it("measures the deviation against the band, not the midpoint", () => {
|
||||
// 6.0 is above the 5.4 midpoint and inside the 4.1-6.8 band, so it is not
|
||||
// a finding. A midpoint comparison would have called it one.
|
||||
expect(render(present())).toContain("inside that range");
|
||||
|
||||
const above = render(
|
||||
present({ selfAssessment: 8, deviation: "above", deviationAmount: 1.2 }),
|
||||
);
|
||||
expect(above).toContain("1.2 points above");
|
||||
});
|
||||
|
||||
it("labels a provisional forecast as provisional", () => {
|
||||
expect(render(present({ provisional: true }))).toContain(
|
||||
'data-slot="mood-prognosis-provisional"',
|
||||
);
|
||||
expect(render(present())).not.toContain(
|
||||
'data-slot="mood-prognosis-provisional"',
|
||||
);
|
||||
});
|
||||
|
||||
it("says which day it is about when that day is not today", () => {
|
||||
const stale = render(present({ current: false, ageDays: 4 }));
|
||||
expect(stale).toContain('data-slot="mood-prognosis-age"');
|
||||
// Rendered through the account's own date-order preference rather than as
|
||||
// the raw day key — under this render's `en` locale and the default AUTO
|
||||
// preference that is month-first. The assertion is on the formatted form
|
||||
// precisely because a raw `2026-08-07` on the screen would pass a looser
|
||||
// one.
|
||||
expect(stale).toContain("08/07/2026");
|
||||
expect(stale).not.toContain("2026-08-07");
|
||||
});
|
||||
|
||||
it("names what the model weighted, in the reader's own words", () => {
|
||||
const html = render(present());
|
||||
// The dimension's own label, the one the slider carries.
|
||||
expect(html).toContain("Stress");
|
||||
expect(html).toContain("Steps");
|
||||
expect(html).toContain("counted towards a higher expected value");
|
||||
expect(html).toContain("counted towards a lower expected value");
|
||||
});
|
||||
|
||||
it("skips a feature key this build no longer knows", () => {
|
||||
const html = render(
|
||||
present({
|
||||
contributions: [
|
||||
{ feature: "retired:whatever", contribution: 2 },
|
||||
{ feature: "dimension:a3", contribution: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).not.toContain("retired:whatever");
|
||||
expect(html).toContain('data-slot="mood-prognosis-explanation"');
|
||||
});
|
||||
|
||||
it("renders nothing at all below the first rung", () => {
|
||||
expect(
|
||||
render({
|
||||
present: false,
|
||||
reason: "no-output-yet",
|
||||
entries: 4,
|
||||
nextThreshold: 15,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("explains the learning phase with the count and the next step", () => {
|
||||
const html = render({
|
||||
present: false,
|
||||
reason: "learning-phase",
|
||||
entries: 22,
|
||||
nextThreshold: 30,
|
||||
});
|
||||
expect(html).toContain("22");
|
||||
expect(html).toContain("30");
|
||||
// Absence, not an error and not a zero.
|
||||
expect(html).not.toContain('data-slot="mood-prognosis-expected"');
|
||||
});
|
||||
|
||||
it("refuses honestly when there are days but no pattern", () => {
|
||||
const html = render({
|
||||
present: false,
|
||||
reason: "no-pattern",
|
||||
entries: 140,
|
||||
nextThreshold: null,
|
||||
});
|
||||
expect(html).toContain("140");
|
||||
expect(html).not.toContain('data-slot="mood-prognosis-statement"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The day's two readings, side by side, with the person's own one leading.
|
||||
*
|
||||
* The copy rules this component exists to hold, stated once so nobody has to
|
||||
* infer them from the strings:
|
||||
*
|
||||
* * **No sentence names a cause.** Not "overtime lowered your mood", not
|
||||
* "poor sleep is why today was hard". What the data supports is "on your
|
||||
* own past days, a value around X would have been expected", and that is
|
||||
* what it says. The ban is a test, not a habit:
|
||||
* `src/__tests__/copy-matches-behaviour-guard.test.ts` reads the six
|
||||
* bundles and fails on a causal construction in any of them.
|
||||
* * **The forecast is a counterfactual and the sentence says so.** Never
|
||||
* "your mood is 5.4" — always "a value around 5.4 would have been
|
||||
* expected". The difference is the whole distinction between the two
|
||||
* readings.
|
||||
* * **The count is on the same surface as the number**, not in a tooltip. A
|
||||
* forecast built from thirty days and one built from three hundred look
|
||||
* identical otherwise, and a reader who cannot see which one they are
|
||||
* looking at cannot weigh it.
|
||||
* * **The deviation is measured against the band, never against the
|
||||
* midpoint.** Inside the band is "as expected"; outside it is the finding.
|
||||
*
|
||||
* The explanation is generated from the contributions the job stored for that
|
||||
* day, so it names what the model actually weighted rather than a fresh
|
||||
* calculation against a model that may since have been re-fitted.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Compass } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
|
||||
|
||||
import { prognosisFeatureLabel } from "./prognosis-feature-label";
|
||||
|
||||
/** How many weighted features the explanation names. */
|
||||
const EXPLAINED_FEATURES = 3;
|
||||
|
||||
function formatValue(value: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function MoodPrognosisCard() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.moodPrognosis(),
|
||||
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="min-h-24 w-full rounded-xl" />;
|
||||
}
|
||||
if (!data) return null;
|
||||
return <MoodPrognosisView reading={data} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The card itself, over a resolved reading.
|
||||
*
|
||||
* Split from the query so the copy rules above can be asserted against real
|
||||
* renders rather than against the strings alone: "the two values are never
|
||||
* merged" is a property of what reaches the screen, and a bundle check cannot
|
||||
* see it.
|
||||
*/
|
||||
export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const dateFormat = useDateFormatPreference();
|
||||
const data = reading;
|
||||
// The day key is a plain calendar date and is rendered as one, through the
|
||||
// account's own date-order preference and pinned to UTC — the same treatment
|
||||
// every other day key in the app gets, so this card cannot show a different
|
||||
// day from the list beside it.
|
||||
const day = data.present ? formatDate(data.date, dateFormat, locale) : "";
|
||||
|
||||
// Below the first rung there is nothing to say and the surface says nothing
|
||||
// rather than showing an empty frame with an apology in it.
|
||||
if (!data.present && data.reason === "no-output-yet") return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="mood-prognosis" className="gap-2 py-3 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader icon={Compass} title={t("insights.mood.prognosis.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!data.present ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="mood-prognosis-absent"
|
||||
>
|
||||
{data.reason === "learning-phase"
|
||||
? t("insights.mood.prognosis.learning", {
|
||||
entries: String(data.entries),
|
||||
threshold: String(data.nextThreshold ?? 0),
|
||||
})
|
||||
: t("insights.mood.prognosis.noPattern", {
|
||||
entries: String(data.entries),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-x-8 gap-y-3">
|
||||
{/* The self-assessment leads: it is the larger number, it comes
|
||||
first, and it is the only one of the two the person gave. */}
|
||||
<div data-slot="mood-prognosis-self">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.selfLabel")}
|
||||
</p>
|
||||
<p className="text-foreground text-3xl font-semibold tabular-nums">
|
||||
{data.selfAssessment === null
|
||||
? t("mood.dayContext.notRecorded")
|
||||
: formatValue(data.selfAssessment, locale)}
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="mood-prognosis-expected">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.expectedLabel")}
|
||||
</p>
|
||||
<p className="text-foreground text-xl font-medium tabular-nums">
|
||||
{formatValue(data.predicted, locale)}
|
||||
</p>
|
||||
{/* The band and the count ride with the value, in the block a
|
||||
screen reader reaches in the same breath. */}
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{data.ciLow !== null && data.ciHigh !== null
|
||||
? t("insights.mood.prognosis.band", {
|
||||
low: formatValue(data.ciLow, locale),
|
||||
high: formatValue(data.ciHigh, locale),
|
||||
})
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-foreground text-sm"
|
||||
data-slot="mood-prognosis-statement"
|
||||
>
|
||||
{t("insights.mood.prognosis.statement", {
|
||||
value: formatValue(data.predicted, locale),
|
||||
date: day,
|
||||
n: String(data.n),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{data.provisional ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="mood-prognosis-provisional"
|
||||
>
|
||||
{t("insights.mood.prognosis.provisional")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!data.current ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="mood-prognosis-age"
|
||||
>
|
||||
{t("insights.mood.prognosis.notToday", { date: day })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.deviation !== null && data.selfAssessment !== null ? (
|
||||
<p
|
||||
className="text-foreground text-sm"
|
||||
data-slot="mood-prognosis-deviation"
|
||||
>
|
||||
{data.deviation === "within"
|
||||
? t("insights.mood.prognosis.deviationWithin")
|
||||
: data.deviation === "above"
|
||||
? t("insights.mood.prognosis.deviationAbove", {
|
||||
amount: formatValue(data.deviationAmount ?? 0, locale),
|
||||
})
|
||||
: t("insights.mood.prognosis.deviationBelow", {
|
||||
amount: formatValue(data.deviationAmount ?? 0, locale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<PrognosisExplanation contributions={data.contributions} />
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.caveat")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PrognosisExplanation({
|
||||
contributions,
|
||||
}: {
|
||||
contributions: Array<{ feature: string; contribution: number }>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const named = contributions
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: prognosisFeatureLabel(item.feature, t),
|
||||
}))
|
||||
.filter(
|
||||
(item): item is typeof item & { label: string } =>
|
||||
item.label !== null && item.contribution !== 0,
|
||||
)
|
||||
.slice(0, EXPLAINED_FEATURES);
|
||||
|
||||
if (named.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1" data-slot="mood-prognosis-explanation">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.prognosis.weightedTitle")}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{named.map((item) => (
|
||||
<li
|
||||
key={item.feature}
|
||||
className="text-foreground text-sm"
|
||||
data-slot="mood-prognosis-weighted-item"
|
||||
>
|
||||
{item.contribution > 0
|
||||
? t("insights.mood.prognosis.weightedUp", { label: item.label })
|
||||
: t("insights.mood.prognosis.weightedDown", {
|
||||
label: item.label,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* A stored feature key, in the reader's own language.
|
||||
*
|
||||
* The keys in `MoodPrediction.features` are stable identifiers, not copy. This
|
||||
* turns one into a label, and it does so out of the strings the capture
|
||||
* surfaces already use: a person reads "Workload" here because that is what
|
||||
* the slider they moved was called, not because a second wording was invented
|
||||
* for the explanation.
|
||||
*
|
||||
* Every key it cannot place returns `null`, and that path is real rather than
|
||||
* defensive: a row written by an older `modelVersion` can name a feature this
|
||||
* build has dropped, and skipping it is better than rendering an identifier at
|
||||
* somebody.
|
||||
*/
|
||||
import { parseFeatureKey } from "@/lib/analytics/mood-prognosis/feature-keys";
|
||||
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
|
||||
import { getMoodDimension } from "@/lib/mood/dimensions";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* The five linked figures, resolved through literal keys.
|
||||
*
|
||||
* A switch rather than a computed key so the call-site coverage guard can see
|
||||
* every string this file can ask for. The keys are the capture sheet's own
|
||||
* (`mood.dayContext.linked*`), which is the point: the label a figure carries
|
||||
* in the explanation is the label it carries where it was read.
|
||||
*/
|
||||
function linkedLabel(metric: string, t: Translate): string | null {
|
||||
switch (metric) {
|
||||
case "sleepAsleep":
|
||||
return t("mood.dayContext.linkedSleep");
|
||||
case "steps":
|
||||
return t("mood.dayContext.linkedSteps");
|
||||
case "activeEnergy":
|
||||
return t("mood.dayContext.linkedActiveEnergy");
|
||||
case "restingHeartRate":
|
||||
return t("mood.dayContext.linkedRestingHeartRate");
|
||||
case "heartRateVariability":
|
||||
return t("mood.dayContext.linkedHrv");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function prognosisFeatureLabel(
|
||||
key: string,
|
||||
t: Translate,
|
||||
): string | null {
|
||||
const parsed = parseFeatureKey(key);
|
||||
if (parsed === null) return null;
|
||||
|
||||
if (parsed.family === "dimension") {
|
||||
const dimension = getMoodDimension(parsed.dimension);
|
||||
return dimension ? t(dimension.labelKey) : null;
|
||||
}
|
||||
if (parsed.family === "linked") return linkedLabel(parsed.metric, t);
|
||||
|
||||
const field = t(`mood.context.field.${parsed.field}`);
|
||||
if (parsed.value === null) return field;
|
||||
// "Working day: longer than usual" — the field and the value the person
|
||||
// picked, joined by a pattern rather than by a hard-coded colon, because the
|
||||
// punctuation between them is a locale's business.
|
||||
return t("insights.mood.prognosis.featureValue", {
|
||||
field,
|
||||
value: t(contextValueLabelKey(parsed.field, parsed.value)),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -25,6 +25,7 @@ import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -118,6 +119,21 @@ export function LabForm({
|
||||
const [value, setValue] = useState("");
|
||||
const [valueText, setValueText] = useState("");
|
||||
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
|
||||
/**
|
||||
* The visit this reading came out of, when the suggestion offered one.
|
||||
*
|
||||
* Deliberately NOT cleared by the keep-open reset below: a real lab report
|
||||
* shares one draw date and one visit across every analyte, so the next row
|
||||
* off the same report keeps both rather than asking again.
|
||||
*/
|
||||
const [visitId, setVisitId] = useState<string | null>(null);
|
||||
// The `DateTimeField` value is a local `yyyy-MM-ddTHH:mm`; the suggestion
|
||||
// asks the server in ISO-8601, so it is converted once here rather than in
|
||||
// the shared field, which must not know about this form's input format.
|
||||
const takenAtAnchor = useMemo(() => {
|
||||
const parsed = new Date(takenAt);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}, [takenAt]);
|
||||
const [note, setNote] = useState("");
|
||||
// The window the paper report printed for THIS reading. Optional and folded
|
||||
// away by default: most entries are typed against a catalog marker and never
|
||||
@@ -224,6 +240,7 @@ export function LabForm({
|
||||
: {}),
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
...(visitId ? { encounterId: visitId } : {}),
|
||||
});
|
||||
toast.success(
|
||||
t("labs.form.savedToast"),
|
||||
@@ -449,6 +466,15 @@ export function LabForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anchored on the draw date, not on now: backdating a reading moves
|
||||
the offer with it. */}
|
||||
<EncounterSuggestionField
|
||||
anchor={takenAtAnchor}
|
||||
value={visitId}
|
||||
onChange={setVisitId}
|
||||
slot="lab-form-encounter-suggestion"
|
||||
/>
|
||||
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { OcrRowEditor } from "./ocr-row-editor";
|
||||
@@ -128,6 +129,12 @@ export function OcrReviewDialog({
|
||||
// filed into the Documents vault and cross-linked to the committed labs. Text
|
||||
// mode keeps the image on-device, so nothing is retained there.
|
||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||
/**
|
||||
* The visit this panel came out of, if the review step offered one and the
|
||||
* person took the offer. Never blocks the save and never pre-fills anything
|
||||
* else: the panel commits with it unset exactly as before.
|
||||
*/
|
||||
const [visitId, setVisitId] = useState<string | null>(null);
|
||||
|
||||
// Text mode OCR's the image in the browser then POSTs the text; vision mode
|
||||
// uploads the image. Both resolve with the same proposed-rows DTO.
|
||||
@@ -143,10 +150,24 @@ export function OcrReviewDialog({
|
||||
[rows],
|
||||
);
|
||||
|
||||
/**
|
||||
* The draw date the whole panel hangs off — the earliest `takenAt` the scan
|
||||
* read. A panel is one draw; asking the suggestion about each row separately
|
||||
* would ask the same question a dozen times and could answer it a dozen ways.
|
||||
*/
|
||||
const panelAnchor = useMemo(() => {
|
||||
const dates = rows
|
||||
.map((row) => row.takenAt)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort();
|
||||
return dates.length > 0 ? `${dates[0]}T12:00:00.000Z` : null;
|
||||
}, [rows]);
|
||||
|
||||
function reset() {
|
||||
setStage("pick");
|
||||
setRows([]);
|
||||
setPickedFile(null);
|
||||
setVisitId(null);
|
||||
extract.reset();
|
||||
commit.reset();
|
||||
}
|
||||
@@ -183,7 +204,7 @@ export function OcrReviewDialog({
|
||||
return;
|
||||
}
|
||||
commit.mutate(
|
||||
{ rows: payload, file: pickedFile },
|
||||
{ rows: payload, file: pickedFile, encounterId: visitId },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
// The commit re-checks each confirmed row against what is already
|
||||
@@ -307,6 +328,16 @@ export function OcrReviewDialog({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("labs.ocr.foundCount", { count: rows.length })}
|
||||
</p>
|
||||
|
||||
{/* Anchored on the draw date the scan read, so the offer is about
|
||||
the panel rather than about today. */}
|
||||
<EncounterSuggestionField
|
||||
anchor={panelAnchor}
|
||||
value={visitId}
|
||||
onChange={setVisitId}
|
||||
slot="labs-ocr-encounter-suggestion"
|
||||
/>
|
||||
|
||||
{rows.map((row) => (
|
||||
<OcrRowEditor
|
||||
key={row.key}
|
||||
|
||||
@@ -143,6 +143,12 @@ export interface OcrCommitInput {
|
||||
* image stays on-device.
|
||||
*/
|
||||
file?: File | null;
|
||||
/**
|
||||
* The visit this panel came out of, when the review step offered one and the
|
||||
* person took the offer. Optional always — a commit with none named succeeds
|
||||
* unchanged.
|
||||
*/
|
||||
encounterId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,13 +172,14 @@ async function fileScanToVault(file: File): Promise<string | undefined> {
|
||||
export function useOcrCommit() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
|
||||
mutationFn: async ({ rows, file }: OcrCommitInput) => {
|
||||
mutationFn: async ({ rows, file, encounterId }: OcrCommitInput) => {
|
||||
const documentId = file
|
||||
? await fileScanToVault(file).catch(() => undefined)
|
||||
: undefined;
|
||||
return apiPost<OcrCommitResult>("/api/labs/ocr/commit", {
|
||||
rows,
|
||||
...(documentId ? { documentId } : {}),
|
||||
...(encounterId ? { encounterId } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: (_result, { file }) => {
|
||||
|
||||
@@ -132,6 +132,7 @@ const OWNER_READ: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
username: "grandma",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
@@ -148,6 +149,7 @@ const MANAGED_GUARDIAN: AccountAccessEntry = {
|
||||
accountId: "managed-record",
|
||||
username: "managed-record",
|
||||
displayName: "Managed profile",
|
||||
fullName: null,
|
||||
access: "write" as const,
|
||||
level: "manage" as const,
|
||||
sections: null,
|
||||
|
||||
@@ -56,6 +56,7 @@ const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "grandma",
|
||||
displayName: "Margarethe",
|
||||
fullName: null,
|
||||
access: "read" as const,
|
||||
level: "read" as const,
|
||||
sections: null,
|
||||
@@ -108,6 +109,25 @@ describe("<SharedRecordBanner>", () => {
|
||||
expect(html).toContain("grandma");
|
||||
});
|
||||
|
||||
it("names the owner in full when they have a full name, to a read-only delegate", () => {
|
||||
// v1.37.2 — the banner is the account-access view that renders server-side,
|
||||
// and this OWNER is READ (`canWrite: false`). The full name leads over the
|
||||
// greeting name: a delegate about to log a reading sees whose record it is
|
||||
// as who they are, not by a nickname. A deliberate disclosure to everyone
|
||||
// shared with, read-only included (maintainer decision 2026-08-08).
|
||||
const named = {
|
||||
...OWNER,
|
||||
fullName: "Test Full Name",
|
||||
displayName: "Margarethe",
|
||||
};
|
||||
const html = render(
|
||||
{ accounts: [named], active: named, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
expect(html).toContain('data-slot="shared-record-banner"');
|
||||
expect(html).toContain("Test Full Name");
|
||||
});
|
||||
|
||||
it("says the access is read-only, and carries the way out", () => {
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: OWNER, canSwitch: true },
|
||||
|
||||
@@ -101,11 +101,15 @@ export function AccountSwitcherMenuItems() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Two lines, not three. The access level had a line of its
|
||||
own here and it never fit — a 240 px menu truncated
|
||||
"Kann verwalten" as often as it showed it, and the entry
|
||||
still said which kind of record this is on the line below.
|
||||
The level is what the banner states once you are inside,
|
||||
where it governs what the page lets you do. It stays on
|
||||
the row as `data-access-level` for the browser journeys. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{accessLabel(presentation.access, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{recordKindLabel(presentation.recordKind, t)}
|
||||
</span>
|
||||
@@ -122,16 +126,6 @@ export function AccountSwitcherMenuItems() {
|
||||
);
|
||||
}
|
||||
|
||||
function accessLabel(
|
||||
access: ReturnType<typeof resolveRecordPresentation>["access"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (access === "manage") return t("recordSharing.row.canManage");
|
||||
return access === "view-and-add"
|
||||
? t("recordSharing.switcher.canWrite")
|
||||
: t("recordSharing.switcher.readOnly");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
|
||||
@@ -20,7 +20,14 @@ import { useRouter } from "next/navigation";
|
||||
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import {
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -38,6 +45,15 @@ import {
|
||||
|
||||
const SUMMARY_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* How far ahead an appointment has to be before this card stops mentioning it.
|
||||
*
|
||||
* A dashboard summary answers "what is coming", and two weeks is the horizon a
|
||||
* person plans in. Further out and the line is noise that pushes the checkups
|
||||
* it sits under off the card.
|
||||
*/
|
||||
const APPOINTMENT_HORIZON_DAYS = 14;
|
||||
|
||||
function resolveLabel(
|
||||
reminder: MeasurementReminder,
|
||||
t: (key: string) => string,
|
||||
@@ -54,6 +70,7 @@ export function VorsorgeDashboardCard() {
|
||||
// Issue #490 — the same profile zone the checkups page derives its due
|
||||
// phrase in, so the two screens still agree when the device sits elsewhere.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const format = useFormatters();
|
||||
const router = useRouter();
|
||||
// The same answer `/checkups` gives the same action. Satisfying a reminder
|
||||
// is not one of the verbs a delegation admits at any level
|
||||
@@ -72,6 +89,26 @@ export function VorsorgeDashboardCard() {
|
||||
const showLoading = !reminders && !isError;
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
/**
|
||||
* The next appointment, read from the VISIT list and never from the reminder
|
||||
* table.
|
||||
*
|
||||
* An ENCOUNTER-origin reminder is excluded from every preventive-care read,
|
||||
* which is correct — an appointment is not a checkup, and relaxing that
|
||||
* filter here would put one in the due list. But a notification about
|
||||
* something invisible in the app is worse than no notification, so the
|
||||
* appointment appears where a person looks, sourced from the record it
|
||||
* actually lives in. A diff in this file that touches `origin` is the wrong
|
||||
* fix.
|
||||
*/
|
||||
const visits = useEncounters();
|
||||
const nextAppointment = (visits.data?.upcoming ?? []).find(
|
||||
(visit) =>
|
||||
visit.status === "PLANNED" &&
|
||||
new Date(visit.occurredAt).getTime() - now <=
|
||||
APPOINTMENT_HORIZON_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
|
||||
const [captureFooterEl, setCaptureFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
@@ -183,6 +220,27 @@ export function VorsorgeDashboardCard() {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Visually distinct from a due checkup: a quiet line under the block
|
||||
rather than a fourth row with an action, because there is nothing
|
||||
to do about an appointment except keep it. */}
|
||||
{nextAppointment ? (
|
||||
<Link
|
||||
href="/checkups"
|
||||
data-slot="vorsorge-dashboard-next-visit"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<CalendarClock className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("encounters.dashboard.nextVisit", {
|
||||
what:
|
||||
nextAppointment.practitioner?.name ??
|
||||
encounterKindText(t, nextAppointment.kind as EncounterKind),
|
||||
when: format.date(nextAppointment.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<ResponsiveSheet
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Activity,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -50,6 +51,7 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -238,6 +240,19 @@ export function VorsorgeSection({
|
||||
const [editing, setEditing] = useState<MeasurementReminder | "new" | null>(
|
||||
null,
|
||||
);
|
||||
/**
|
||||
* The due checkup a visit is being filed against, if any.
|
||||
*
|
||||
* The visit sheet does the rest: saving it as DONE satisfies this reminder
|
||||
* through the server's forward-only, idempotent path, so filing a visit AND
|
||||
* ticking the box is one satisfaction rather than an error.
|
||||
*/
|
||||
const [visitForReminder, setVisitForReminder] = useState<string | null>(null);
|
||||
const [visitSession, setVisitSession] = useState(0);
|
||||
const openVisitFor = (reminder: MeasurementReminder) => {
|
||||
setVisitForReminder(reminder.id);
|
||||
setVisitSession((n) => n + 1);
|
||||
};
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
// The value-capture sheet: holds the reminder whose measurement we are
|
||||
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
|
||||
@@ -738,6 +753,7 @@ export function VorsorgeSection({
|
||||
now={now}
|
||||
view={prefs.view}
|
||||
onPrimaryAction={() => onPrimaryAction(reminder)}
|
||||
onFileVisit={() => openVisitFor(reminder)}
|
||||
onRemove={() => remove.mutate(reminder.id)}
|
||||
onEdit={() => openEdit(reminder)}
|
||||
busy={
|
||||
@@ -749,6 +765,18 @@ export function VorsorgeSection({
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<EncounterSheet
|
||||
key={visitSession}
|
||||
open={visitForReminder !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVisitForReminder(null);
|
||||
}}
|
||||
encounter={null}
|
||||
reminderId={visitForReminder}
|
||||
// The reminder list repaints from the encounter write's own
|
||||
// invalidation; nothing extra to do here.
|
||||
onSaved={() => setVisitForReminder(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -779,6 +807,7 @@ function VorsorgeCard({
|
||||
now,
|
||||
view,
|
||||
onPrimaryAction,
|
||||
onFileVisit,
|
||||
onRemove,
|
||||
onEdit,
|
||||
busy,
|
||||
@@ -787,6 +816,8 @@ function VorsorgeCard({
|
||||
now: number;
|
||||
view: "cards" | "list";
|
||||
onPrimaryAction: () => void;
|
||||
/** Open the visit sheet with this checkup pre-filled, closing it on save. */
|
||||
onFileVisit: () => void;
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
busy: boolean;
|
||||
@@ -912,6 +943,30 @@ function VorsorgeCard({
|
||||
// "do it now" wash; a button that changes colour by schedule state reads as
|
||||
// a different control). Due-ness is communicated by the discreet coloured
|
||||
// next-due text above.
|
||||
/**
|
||||
* "Termin eintragen" — the second way to close a checkup.
|
||||
*
|
||||
* Beside "erledigt" rather than instead of it, because the two answer
|
||||
* different questions: one says the thing happened, the other says WHERE it
|
||||
* happened and files the record of it. Saving the visit as DONE satisfies
|
||||
* this reminder through the same forward-only, idempotent primitive the
|
||||
* checkbox uses, so a person who does both gets one satisfaction and no
|
||||
* error.
|
||||
*/
|
||||
const fileVisitButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
data-slot="vorsorge-file-visit"
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{t("measurementReminders.fileVisit")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const primaryButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1003,6 +1058,24 @@ function VorsorgeCard({
|
||||
: t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Icon-only in the dense list, labelled in the cards branch —
|
||||
the same action either way, never one branch offering what
|
||||
the other withholds. */}
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
data-slot="vorsorge-file-visit"
|
||||
aria-label={t("measurementReminders.fileVisit")}
|
||||
title={t("measurementReminders.fileVisit")}
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
{headerActions}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1129,7 +1202,10 @@ function VorsorgeCard({
|
||||
|
||||
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
|
||||
{primaryButton ? (
|
||||
<div className="mt-auto pt-0">{primaryButton}</div>
|
||||
<div className="mt-auto space-y-1.5 pt-0">
|
||||
{primaryButton}
|
||||
{fileVisitButton}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The Radix Dialog portals at runtime, so its body never materialises in
|
||||
// static markup. Collapse the primitives to plain wrappers (same trick as
|
||||
// the ResearchModeAcknowledgmentDialog suite) so the footer is reachable.
|
||||
// the dose-history dialog suite) so the footer is reachable.
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — Research-mode acknowledgment dialog tests.
|
||||
*
|
||||
* The dialog is rendered controlled by Settings (commit 3) and gates
|
||||
* the GLP-1 drug-level chart (commit 2). The contract is:
|
||||
*
|
||||
* 1. Multi-section MDR copy renders (what-is, what-isn't, why,
|
||||
* MDR boundary, citations).
|
||||
* 2. Acknowledge CTA fires a `POST /api/auth/me/research-mode` with
|
||||
* the exact server-supplied version string.
|
||||
* 3. Cancel CTA fires `onOpenChange(false)` without an API call.
|
||||
* 4. The stale-version error (server 400 with
|
||||
* `research-mode.version.stale`) surfaces a localised error toast
|
||||
* and keeps the dialog open.
|
||||
*
|
||||
* Setup mirrors the existing `coach-settings-sheet.test.tsx` pattern:
|
||||
* SSR via `renderToStaticMarkup` so the Radix Dialog primitives are
|
||||
* mocked to plain wrappers (Portals don't materialise under SSR), and
|
||||
* the TanStack Query mutation is captured so we can drive the
|
||||
* onSuccess / onError branches by hand.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const mutationCallbacks: {
|
||||
mutationFn: ((version: string) => Promise<unknown>) | null;
|
||||
onSuccess: ((data: unknown) => void) | null;
|
||||
onError: ((err: unknown) => void) | null;
|
||||
} = { mutationFn: null, onSuccess: null, onError: null };
|
||||
|
||||
const lastMutateCall: { args: unknown[] } = { args: [] };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: (opts: {
|
||||
mutationFn: (version: string) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown) => void;
|
||||
onError?: (err: unknown) => void;
|
||||
}) => {
|
||||
mutationCallbacks.mutationFn = opts.mutationFn;
|
||||
mutationCallbacks.onSuccess = opts.onSuccess ?? null;
|
||||
mutationCallbacks.onError = opts.onError ?? null;
|
||||
return {
|
||||
mutate: (version: string) => {
|
||||
lastMutateCall.args = [version];
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// v1.4.27 R4 RC2 — the dialog now mounts <ResponsiveSheet>. Its
|
||||
// internals (Sheet on `<md`, Dialog on `md+`) both portal at runtime;
|
||||
// we collapse the primitive to a plain wrapper so the body of the
|
||||
// dialog stays reachable in the static markup.
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="mock-responsive-sheet-header">
|
||||
<h2 data-slot="mock-responsive-sheet-title">{title}</h2>
|
||||
{description ? (
|
||||
<p data-slot="mock-responsive-sheet-description">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-slot="mock-responsive-sheet-body">{children}</div>
|
||||
{footer ? (
|
||||
<div data-slot="mock-responsive-sheet-footer">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { ResearchModeAcknowledgmentDialog } from "../research-mode-acknowledgment-dialog";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mutationCallbacks.mutationFn = null;
|
||||
mutationCallbacks.onSuccess = null;
|
||||
mutationCallbacks.onError = null;
|
||||
lastMutateCall.args = [];
|
||||
vi.mocked(toast.success).mockClear();
|
||||
vi.mocked(toast.error).mockClear();
|
||||
});
|
||||
|
||||
describe("<ResearchModeAcknowledgmentDialog>", () => {
|
||||
it("renders nothing while `open === false`", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={false}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the title + the five MDR-copy sections while open", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Title + primary CTAs.
|
||||
expect(html).toContain("Enable Research Mode");
|
||||
expect(html).toContain("I understand — enable");
|
||||
expect(html).toContain("Cancel");
|
||||
|
||||
// Each of the five sections renders its own headline.
|
||||
expect(html).toContain("What this is");
|
||||
expect(html).toContain("What this isn't");
|
||||
expect(html).toContain("Why it's an estimate");
|
||||
expect(html).toContain("Regulatory boundary");
|
||||
expect(html).toContain("Sources");
|
||||
|
||||
// Cited regulatory references must appear verbatim in the
|
||||
// user-facing copy (project-voice + research §11 + §12.4).
|
||||
expect(html).toContain("2017/745");
|
||||
expect(html).toContain("MDCG 2021-24");
|
||||
// Peer-reviewed citation.
|
||||
expect(html).toContain("10.1002/psp4.13099");
|
||||
|
||||
// Version line surfaces the live server value.
|
||||
expect(html).toContain("Disclaimer version: 2026-05-14.1");
|
||||
});
|
||||
|
||||
it("acknowledge CTA invokes the mutation with the supplied version", async () => {
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// SSR markup can't fire DOM events; invoke the mutation directly
|
||||
// (the production component calls `mutation.mutate(version)` from
|
||||
// the click handler with `currentDisclaimerVersion`).
|
||||
expect(mutationCallbacks.mutationFn).not.toBeNull();
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => ({ data: { enabled: true } }),
|
||||
// The apiFetch wrapper reads the envelope via `text()`.
|
||||
text: async () => JSON.stringify({ data: { enabled: true } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await mutationCallbacks.mutationFn?.("2026-05-14.1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/auth/me/research-mode");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
acknowledged: true,
|
||||
version: "2026-05-14.1",
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("onSuccess fires the localised success toast", () => {
|
||||
const onAcknowledged = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
onAcknowledged={onAcknowledged}
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onSuccess?.({ enabled: true });
|
||||
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0][0]).toContain(
|
||||
"Research Mode enabled",
|
||||
);
|
||||
expect(onAcknowledged).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("stale-version error surfaces the localised error toast and keeps the dialog open", () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onError?.(new Error("research-mode.version.stale"));
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
|
||||
"disclaimer was updated",
|
||||
);
|
||||
// The error path never closes the dialog — the caller stays put
|
||||
// so the user can reload and re-read the new copy.
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generic error path surfaces the generic error toast", () => {
|
||||
render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
);
|
||||
|
||||
mutationCallbacks.onError?.(new Error("http-500"));
|
||||
|
||||
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
|
||||
"Could not enable Research Mode",
|
||||
);
|
||||
});
|
||||
|
||||
it("acknowledge CTA is disabled until the server-supplied version arrives", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The disabled flag stamps as `disabled=""` on a server-rendered button.
|
||||
// The acknowledge button carries `data-slot="research-mode-acknowledge"`.
|
||||
const acknowledgeFragment = html.match(
|
||||
/<button[^>]*data-slot="research-mode-acknowledge"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(acknowledgeFragment).toBeDefined();
|
||||
expect(acknowledgeFragment).toMatch(/\sdisabled(="|\s|>)/);
|
||||
|
||||
// The version line is hidden when null — guard against leaking
|
||||
// `version: null` text into the UI.
|
||||
expect(html).not.toContain("Disclaimer version:");
|
||||
});
|
||||
|
||||
it("renders German copy when locale='de'", () => {
|
||||
const html = render(
|
||||
<ResearchModeAcknowledgmentDialog
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
currentDisclaimerVersion="2026-05-14.1"
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Forschungsmodus aktivieren");
|
||||
expect(html).toContain("Verstanden — aktivieren");
|
||||
expect(html).toContain("Abbrechen");
|
||||
expect(html).toContain("2017/745");
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — Research-mode MDR acknowledgment dialog.
|
||||
*
|
||||
* Opt-in gate in front of the GLP-1 drug-level chart. The chart paints
|
||||
* a unit-less Bateman PK curve over the user's logged intakes (research
|
||||
* §2.3 / §9.6.7). Because a "drug-level" surface is the brightest EU
|
||||
* MDR Class IIa edge in HealthLog's surface area (research §11 + §12.4),
|
||||
* the chart never paints until the user has explicitly read and
|
||||
* acknowledged the disclaimer below. The acknowledgment record is
|
||||
* server-stamped with `RESEARCH_MODE_DISCLAIMER_VERSION`; if the copy
|
||||
* changes the user re-acknowledges before the chart unhides.
|
||||
*
|
||||
* The dialog is rendered controlled by the Settings toggle and the
|
||||
* "re-prompt" banner (commit 3 of this phase). It accepts:
|
||||
* - `open` / `onOpenChange` for full external control
|
||||
* - `currentDisclaimerVersion` — the server's authoritative version
|
||||
* string. Acknowledgment posts THIS value, not the imported
|
||||
* constant — server redeploys that bump the constant must be
|
||||
* reflected without a client reload (W19c-Backend phase report).
|
||||
* - `onAcknowledged()` — fires after a successful 200; the caller
|
||||
* re-fetches the research-mode state and renders the toggle in
|
||||
* the new state.
|
||||
*
|
||||
* Copy direction (project-voice, English, no AI/phase/wave mentions):
|
||||
* - "What this is" — single-paragraph framing: the chart is an
|
||||
* estimate from EMA-published population PK.
|
||||
* - "What this isn't" — explicit boundary: not a measurement, not
|
||||
* personalised, not medical advice.
|
||||
* - "Why it's an estimate" — population PK + IIV; per-patient
|
||||
* variation is large.
|
||||
* - "MDR boundary" — cite EU 2017/745 and MDCG 2021-24 verbatim.
|
||||
* - "Citations" — EMA EPAR + Schneck/Urva 2024 (ASCPT psp4.13099).
|
||||
*
|
||||
* Toast surface uses sonner (the project convention) so the user gets
|
||||
* a transient confirmation on either success or rejection.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface ResearchModeAcknowledgmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
/**
|
||||
* The version string from `GET /api/auth/me/research-mode`. The
|
||||
* server compares it byte-for-byte against the current constant; a
|
||||
* stale value forces a 400 from the API which we surface as an
|
||||
* error toast and keep the dialog open. Pass `null` while the
|
||||
* GET is in flight; the Acknowledge CTA stays disabled.
|
||||
*/
|
||||
currentDisclaimerVersion: string | null;
|
||||
/**
|
||||
* Fires after the POST returns 200. Caller invalidates the
|
||||
* `research-mode` query so the toggle reflects the new state.
|
||||
*/
|
||||
onAcknowledged?: () => void;
|
||||
}
|
||||
|
||||
export function ResearchModeAcknowledgmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentDisclaimerVersion,
|
||||
onAcknowledged,
|
||||
}: ResearchModeAcknowledgmentDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (version: string) => {
|
||||
// ApiError.message carries the envelope `error` string verbatim, so
|
||||
// the stale-version code below still matches on `err.message`.
|
||||
return (
|
||||
(await apiPost("/api/auth/me/research-mode", {
|
||||
acknowledged: true,
|
||||
version,
|
||||
})) ?? null
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setErrorMessage(null);
|
||||
toast.success(t("medications.researchMode.dialog.successToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
||||
onAcknowledged?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
// The 400 stale-version path is the only one we surface
|
||||
// explicitly — every other failure mode (401, 429, 500, network)
|
||||
// collapses to a generic error so the dialog stays open but the
|
||||
// user knows the acknowledgment didn't persist.
|
||||
const code = err instanceof Error ? err.message : "unknown";
|
||||
const isStale = code === "research-mode.version.stale";
|
||||
const message = isStale
|
||||
? t("medications.researchMode.dialog.staleVersionToast")
|
||||
: t("medications.researchMode.dialog.errorToast");
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
function handleAcknowledge() {
|
||||
if (!currentDisclaimerVersion) return;
|
||||
mutation.mutate(currentDisclaimerVersion);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setErrorMessage(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-foreground h-5 w-5 shrink-0" />
|
||||
{t("medications.researchMode.dialog.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("medications.researchMode.dialog.intro")}
|
||||
className="sm:max-w-xl"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={mutation.isPending}
|
||||
data-slot="research-mode-cancel"
|
||||
>
|
||||
{t("medications.researchMode.dialog.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAcknowledge}
|
||||
disabled={mutation.isPending || !currentDisclaimerVersion}
|
||||
data-slot="research-mode-acknowledge"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("medications.researchMode.dialog.acknowledgeCta")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="research-mode-acknowledgment-dialog"
|
||||
className="space-y-4 text-sm"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="research-mode-what-it-is"
|
||||
data-slot="research-mode-what-it-is"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-what-it-is"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whatItIsHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whatItIs")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-what-it-isnt"
|
||||
data-slot="research-mode-what-it-isnt"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-what-it-isnt"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whatItIsntHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whatItIsnt")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-why-estimate"
|
||||
data-slot="research-mode-why-estimate"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-why-estimate"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.whyEstimateHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.whyEstimate")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-mdr"
|
||||
data-slot="research-mode-mdr-boundary"
|
||||
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-mdr"
|
||||
className="text-foreground flex items-center gap-2 font-medium"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-warning h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
|
||||
</h3>
|
||||
<p className="text-foreground/85 mt-1">
|
||||
{t("medications.researchMode.dialog.mdrBoundary")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="research-mode-citations"
|
||||
data-slot="research-mode-citations"
|
||||
>
|
||||
<h3
|
||||
id="research-mode-citations"
|
||||
className="text-foreground font-medium"
|
||||
>
|
||||
{t("medications.researchMode.dialog.citationsHeader")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t("medications.researchMode.dialog.citations")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{currentDisclaimerVersion && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs italic"
|
||||
data-slot="research-mode-version"
|
||||
>
|
||||
{t("medications.researchMode.dialog.versionLine", {
|
||||
version: currentDisclaimerVersion,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="research-mode-error"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,17 @@ export function MentalWellbeing() {
|
||||
description={t("mentalHealth.pageDescription")}
|
||||
/>
|
||||
|
||||
{/* What happens to the answers. The Modules hub used to carry this
|
||||
as the second sentence of the module's one-line description,
|
||||
which was the only place in the app that said it — so it moved
|
||||
to the screen where somebody is about to answer. */}
|
||||
<p
|
||||
data-slot="mental-health-privacy-note"
|
||||
className="text-muted-foreground -mt-3 text-xs"
|
||||
>
|
||||
{t("mentalHealth.privacyNote")}
|
||||
</p>
|
||||
|
||||
<section aria-label={t("mentalHealth.choosePrompt")}>
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
{historyError ? (
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The account's own address book of doctors and practices.
|
||||
*
|
||||
* **Record content, not configuration**, so it does not live in Settings: a
|
||||
* settings surface configures behaviour, and a list of one's own doctors
|
||||
* configures nothing. It sits beside the visits that name it.
|
||||
*
|
||||
* Deleting is a soft delete and the visits that referenced the entry keep it.
|
||||
* The confirmation says so in one sentence, because a person deleting a doctor
|
||||
* has every reason to fear their history goes with it.
|
||||
*/
|
||||
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
usePractitionerMutations,
|
||||
usePractitioners,
|
||||
type Practitioner,
|
||||
} from "@/hooks/use-practitioners";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
const list = usePractitioners(debounced, enabled);
|
||||
const { remove } = usePractitionerMutations();
|
||||
|
||||
const [editing, setEditing] = useState<Practitioner | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Practitioner | null>(null);
|
||||
// Bumped on every open so the sheet remounts with a fresh draft rather than
|
||||
// copying props into state from an effect.
|
||||
const [session, setSession] = useState(0);
|
||||
const openSheet = (target: Practitioner | "new") => {
|
||||
setEditing(target);
|
||||
setSession((n) => n + 1);
|
||||
};
|
||||
|
||||
const rows = list.data ?? [];
|
||||
|
||||
const addButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="practitioner-add"
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("practitioners.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<PageHeader
|
||||
title={t("practitioners.title")}
|
||||
description={t("practitioners.description")}
|
||||
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={t("practitioners.searchPlaceholder")}
|
||||
aria-label={t("practitioners.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
data-slot="practitioner-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("practitioners.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ContactRound className="size-6" aria-hidden />}
|
||||
title={t(
|
||||
debounced
|
||||
? "practitioners.noMatchTitle"
|
||||
: "practitioners.emptyTitle",
|
||||
)}
|
||||
description={t(
|
||||
debounced
|
||||
? "practitioners.noMatchDescription"
|
||||
: "practitioners.emptyDescription",
|
||||
)}
|
||||
action={debounced ? undefined : (addButton ?? undefined)}
|
||||
ctaSize="lg"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2" data-slot="practitioner-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id}>
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{row.name}
|
||||
</p>
|
||||
{row.practice || row.specialty ? (
|
||||
<p className="text-foreground truncate text-sm">
|
||||
{[row.practice, row.specialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{row.location ? (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{row.location}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => openSheet(row)}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<PractitionerSheet
|
||||
key={session}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
practitioner={editing === "new" ? null : editing}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("practitioners.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
{/* The one sentence that matters: the old visits stay. */}
|
||||
<AlertDialogDescription>
|
||||
{t("practitioners.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!deleting) return;
|
||||
const id = deleting.id;
|
||||
setDeleting(null);
|
||||
remove.mutate(id, {
|
||||
onError: () => toast.error(t("practitioners.deleteFailed")),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Add or correct one address-book entry.
|
||||
*
|
||||
* Six plaintext-ish fields and one encrypted note. `name` is the only required
|
||||
* one, for the same reason the visit only requires a date: a person typing a
|
||||
* practice into a form after a doctor's appointment should not be stopped by a
|
||||
* phone number they do not have to hand.
|
||||
*
|
||||
* Reachable two ways — from the address-book page and nested inside the visit
|
||||
* form's picker — so it owns no navigation of its own and reports what it
|
||||
* created back to whoever opened it.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
usePractitionerMutations,
|
||||
type Practitioner,
|
||||
type PractitionerWriteBody,
|
||||
} from "@/hooks/use-practitioners";
|
||||
|
||||
interface Draft {
|
||||
name: string;
|
||||
specialty: string;
|
||||
practice: string;
|
||||
location: string;
|
||||
phone: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const EMPTY: Draft = {
|
||||
name: "",
|
||||
specialty: "",
|
||||
practice: "",
|
||||
location: "",
|
||||
phone: "",
|
||||
note: "",
|
||||
};
|
||||
|
||||
function draftFrom(row: Practitioner | null): Draft {
|
||||
if (!row) return EMPTY;
|
||||
return {
|
||||
name: row.name,
|
||||
specialty: row.specialty ?? "",
|
||||
practice: row.practice ?? "",
|
||||
location: row.location ?? "",
|
||||
phone: row.phone ?? "",
|
||||
note: row.note ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** An empty field clears the column rather than writing an empty string. */
|
||||
function orNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function PractitionerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
practitioner,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** `null` creates; a row edits it. */
|
||||
practitioner: Practitioner | null;
|
||||
onSaved?: (row: Practitioner) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update } = usePractitionerMutations();
|
||||
// Seeded once, at mount. The caller varies this component's `key` on every
|
||||
// open, so a second edit never shows the first one's values and a create
|
||||
// after an edit starts empty — without an effect that copies props into
|
||||
// state and briefly renders the stale pair.
|
||||
const [draft, setDraft] = useState<Draft>(() => draftFrom(practitioner));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pending = create.isPending || update.isPending;
|
||||
const nameGiven = draft.name.trim().length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!nameGiven) return;
|
||||
const body: PractitionerWriteBody = {
|
||||
name: draft.name.trim(),
|
||||
specialty: orNull(draft.specialty),
|
||||
practice: orNull(draft.practice),
|
||||
location: orNull(draft.location),
|
||||
phone: orNull(draft.phone),
|
||||
note: orNull(draft.note),
|
||||
};
|
||||
try {
|
||||
const saved = practitioner
|
||||
? await update.mutateAsync({ id: practitioner.id, body })
|
||||
: await create.mutateAsync(body);
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("practitioners.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (key: keyof Draft) => ({
|
||||
value: draft[key],
|
||||
onChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => setDraft((prev) => ({ ...prev, [key]: event.target.value })),
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(
|
||||
practitioner ? "practitioners.editTitle" : "practitioners.createTitle",
|
||||
)}
|
||||
description={t("practitioners.formDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!nameGiven || pending}
|
||||
onClick={() => void submit()}
|
||||
data-slot="practitioner-save"
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="practitioner-form">
|
||||
<FieldGroup htmlFor="practitioner-name" label={t("practitioners.name")}>
|
||||
<Input
|
||||
id="practitioner-name"
|
||||
required
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
{...field("name")}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-specialty"
|
||||
label={t("practitioners.specialty")}
|
||||
>
|
||||
<Input id="practitioner-specialty" {...field("specialty")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-practice"
|
||||
label={t("practitioners.practice")}
|
||||
>
|
||||
<Input id="practitioner-practice" {...field("practice")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-location"
|
||||
label={t("practitioners.location")}
|
||||
>
|
||||
<Input id="practitioner-location" {...field("location")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-phone"
|
||||
label={t("practitioners.phone")}
|
||||
>
|
||||
<Input id="practitioner-phone" type="tel" {...field("phone")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-note"
|
||||
label={t("practitioners.note")}
|
||||
hint={t("practitioners.noteHint")}
|
||||
>
|
||||
<Textarea id="practitioner-note" rows={3} {...field("note")} />
|
||||
</FieldGroup>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,6 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19c-Frontend — `<AdvancedSection>` Research Mode card tests.
|
||||
*
|
||||
* The card lives next to the danger-zone delete and drives three
|
||||
* server interactions:
|
||||
*
|
||||
* GET /api/auth/me/research-mode on mount, via TanStack Query
|
||||
* POST /api/auth/me/research-mode via the acknowledgment dialog
|
||||
* DELETE /api/auth/me/research-mode directly when the user
|
||||
* toggles OFF
|
||||
*
|
||||
* The toggle ON path opens the dialog; only the dialog's CTA fires
|
||||
* the POST (so the user reads the disclaimer first). OFF fires
|
||||
* DELETE directly. A version-mismatch banner renders above the
|
||||
* toggle whenever `acknowledgedVersion !== currentDisclaimerVersion`
|
||||
* even when `enabled === true`.
|
||||
*
|
||||
* The dialog itself is covered by
|
||||
* `research-mode-acknowledgment-dialog.test.tsx`; we mock it down to a
|
||||
* marker here so the toggle behaviour is the only contract under
|
||||
* test.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const queryResults: Record<string, unknown> = {};
|
||||
|
||||
function setQueryResult(keyJoined: string, data: unknown) {
|
||||
queryResults[keyJoined] = data;
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = (queryKey as Array<string | number>).join("/");
|
||||
return {
|
||||
data: queryResults[key],
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// The acknowledgment dialog is exercised in its own spec; mock the
|
||||
// import to a transparent marker so we can assert "the toggle ON path
|
||||
// renders the dialog with the right props" without re-running the
|
||||
// dialog tree.
|
||||
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
|
||||
ResearchModeAcknowledgmentDialog: ({
|
||||
open,
|
||||
currentDisclaimerVersion,
|
||||
}: {
|
||||
open: boolean;
|
||||
currentDisclaimerVersion: string | null;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-ack-dialog"
|
||||
data-open={open ? "true" : "false"}
|
||||
data-version={currentDisclaimerVersion ?? ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(queryResults)) delete queryResults[k];
|
||||
});
|
||||
|
||||
describe("<AdvancedSection> → Research Mode card", () => {
|
||||
it("renders the section title + subtitle + toggle in OFF state", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-card"');
|
||||
expect(html).toContain("Research Mode");
|
||||
expect(html).toContain("Show the estimated drug-level chart");
|
||||
// OFF state status line.
|
||||
expect(html).toContain("Disabled. The drug-level chart is hidden.");
|
||||
// No re-prompt banner when versions already align (or the user has
|
||||
// never acknowledged in the first place).
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is unchecked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toBeDefined();
|
||||
expect(switchTag).not.toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the acknowledged-on status when enabled + versions aligned", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-05-14T08:30:00Z",
|
||||
acknowledgedVersion: "2026-05-14.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// Acknowledged-on copy carries the formatted date (the i18n
|
||||
// template uses `{date}`; the formatter renders the day).
|
||||
expect(html).toMatch(/Acknowledged on/);
|
||||
// Banner stays hidden when versions match.
|
||||
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
|
||||
// Switch is checked.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("renders the re-prompt banner when versions diverge", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-04-01T00:00:00Z",
|
||||
acknowledgedVersion: "2026-04-01.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
|
||||
expect(html).toContain("Disclaimer updated");
|
||||
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
|
||||
expect(html).toContain("Re-acknowledge disclaimer");
|
||||
// Status line reflects the stale state without claiming
|
||||
// "acknowledged on …" — that copy would mis-represent that the
|
||||
// chart is currently painting.
|
||||
expect(html).not.toMatch(/Acknowledged on/);
|
||||
expect(html).toContain(
|
||||
"The disclaimer was updated. Re-acknowledge below to bring the chart back.",
|
||||
);
|
||||
// The toggle stays "on" — we surface the previous choice; the
|
||||
// banner explains why the chart isn't painting.
|
||||
const switchTag = html.match(
|
||||
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(switchTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("forwards the current server-supplied version to the dialog", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-06-01.2",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />);
|
||||
|
||||
// The dialog renders even when closed (its `open` prop is what
|
||||
// controls visibility); the data-version attribute lets us verify
|
||||
// the parent passes the live version string through.
|
||||
expect(html).toContain('data-slot="mock-ack-dialog"');
|
||||
expect(html).toMatch(/data-version="2026-06-01\.2"/);
|
||||
expect(html).toMatch(/data-open="false"/);
|
||||
});
|
||||
|
||||
it("renders German copy when locale='de'", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: false,
|
||||
acknowledgedAt: null,
|
||||
acknowledgedVersion: null,
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
|
||||
const html = render(<AdvancedSection />, "de");
|
||||
|
||||
expect(html).toContain("Forschungsmodus");
|
||||
expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen");
|
||||
expect(html).toContain("Deaktiviert");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* A module row in the "What you track" hub gets ONE sentence, same as a card
|
||||
* header's description slot (design standards §3).
|
||||
*
|
||||
* Five of the eighteen rows had grown a second sentence — always the same
|
||||
* one, "Off by default", and in one case a third clause after it. The
|
||||
* neighbours sat at 30-75 characters and those five at 120-165, so the hub
|
||||
* read as two different lists stacked on each other. The switch beside the
|
||||
* row already says whether the module is on, so the sentence was restating
|
||||
* what the control shows.
|
||||
*
|
||||
* The single-sentence half of the rule is checked in every locale: it is a
|
||||
* structural claim, not a length one, and a translation that re-adds the
|
||||
* clause is the same defect. The character cap is checked in EN and DE, the
|
||||
* two the maintainer reads, matching `card-description-length.test.ts`.
|
||||
*/
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const MAX_CHARS = 120;
|
||||
|
||||
function bundle(locale: string): Record<string, { description?: string }> {
|
||||
const raw = readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8");
|
||||
return JSON.parse(raw).modules;
|
||||
}
|
||||
|
||||
describe("module hub descriptions", () => {
|
||||
it("names a description for every module in every locale", () => {
|
||||
// Positive control: without it, a renamed `modules` namespace would make
|
||||
// the two checks below iterate an empty set and report green.
|
||||
for (const locale of locales) {
|
||||
const modules = bundle(locale);
|
||||
const missing = MODULE_KEYS.filter(
|
||||
(key) => !modules[key]?.description?.trim(),
|
||||
);
|
||||
expect(missing, `${locale} is missing descriptions`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds each one to a single sentence, in every locale", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const locale of locales) {
|
||||
const modules = bundle(locale);
|
||||
for (const key of MODULE_KEYS) {
|
||||
const text = modules[key].description!;
|
||||
// Drop the closing terminator, then any sentence break left inside
|
||||
// is a second sentence. A semicolon splice counts — it is how the
|
||||
// longest of the five carried its third clause.
|
||||
const body = text.replace(/[.!?…]\s*$/, "");
|
||||
if (/[.!?;]\s/.test(body)) {
|
||||
offenders.push(`${locale}/${key}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds each one under the character guideline in EN and DE", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
const modules = bundle(locale);
|
||||
for (const key of MODULE_KEYS) {
|
||||
const text = modules[key].description!;
|
||||
if (text.length > MAX_CHARS) {
|
||||
offenders.push(`${locale}/${key}: ${text.length} chars`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
* canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing
|
||||
* per-column endpoints — never the module-allowlist the gate ignores for them.
|
||||
*
|
||||
* Test strategy mirrors `advanced-research-mode.test.tsx`: mock
|
||||
* Test strategy mirrors `advanced-account-delete.test.tsx`: mock
|
||||
* `@tanstack/react-query` so the rendered tree executes under SSR
|
||||
* (`renderToStaticMarkup`), capture the `useMutation` config to assert the
|
||||
* PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to
|
||||
@@ -163,11 +163,20 @@ describe("<ModulesSection>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a manage deep-link beside the delegated coach + cycle switches", () => {
|
||||
it("keeps a manage deep-link beside the delegated coach switch", () => {
|
||||
const html = render();
|
||||
// Coach → Coach settings; cycle → the Account cycle-tracking card.
|
||||
// Coach → Coach settings. Coach is the only delegated module with more
|
||||
// to configure than on/off (cadence, memory) at a settings address.
|
||||
expect(html).toContain('href="/settings/coach"');
|
||||
expect(html).toContain('href="/settings/account#cycle-tracking"');
|
||||
});
|
||||
|
||||
it("offers no Account deep-link beside the cycle switch", () => {
|
||||
// Cycle used to carry a second enable surface in Settings → Account,
|
||||
// and this link pointed at it. The switch here drives the same column,
|
||||
// so the Account card went and the link with it — a deep-link to an
|
||||
// anchor nothing renders is a door into a wall.
|
||||
const html = render();
|
||||
expect(html).not.toContain("/settings/account#cycle-tracking");
|
||||
});
|
||||
|
||||
it("no longer renders the 'Always on' core-domains card", () => {
|
||||
|
||||
@@ -67,6 +67,20 @@ describe("SettingsCard — the container owns the rhythm", () => {
|
||||
const html = render(<SettingsCard className="gap-2">body</SettingsCard>);
|
||||
expect(html).toContain("gap-2");
|
||||
});
|
||||
|
||||
it("drops the gap as well as the padding on a flush ledger card", () => {
|
||||
// Dropping only the padding leaves the card's own gap standing between
|
||||
// the rows as dead space the first row does not have. Both breakpoints
|
||||
// are named because the `<Card>` primitive carries a `md:` gap that a
|
||||
// bare `gap-0` loses to.
|
||||
const html = render(<SettingsCard flush>rows</SettingsCard>);
|
||||
expect(html).toContain("gap-0");
|
||||
expect(html).toContain("md:gap-0");
|
||||
expect(html).toContain("p-0");
|
||||
expect(html).toContain("md:p-0");
|
||||
expect(html).not.toMatch(/class="[^"]*\bgap-4\b/);
|
||||
expect(html).not.toMatch(/class="[^"]*\bp-4\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsCardHeader — one description sentence, one text run", () => {
|
||||
|
||||
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user