mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish
This commit is contained in:
commit
4c2dfa57b4
138 files changed
+12046
-137
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
|
||||
|
||||
+405
-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:
|
||||
@@ -11397,6 +11464,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
|
||||
@@ -11515,6 +11588,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:
|
||||
@@ -11618,6 +11697,8 @@ paths:
|
||||
- setKind
|
||||
- linkEpisode
|
||||
- unlinkEpisode
|
||||
- linkEncounter
|
||||
- unlinkEncounter
|
||||
- delete
|
||||
- restore
|
||||
kind:
|
||||
@@ -11636,6 +11717,10 @@ paths:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
encounterId:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
required:
|
||||
- ids
|
||||
- action
|
||||
@@ -11720,6 +11805,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.
|
||||
@@ -15647,7 +15739,7 @@ components:
|
||||
type: number
|
||||
const: 2
|
||||
leaves:
|
||||
maxItems: 91
|
||||
maxItems: 92
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -16919,7 +17011,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
|
||||
@@ -16932,6 +17024,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
|
||||
@@ -32815,6 +32908,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:
|
||||
@@ -34845,8 +35116,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:
|
||||
@@ -34923,6 +35196,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:
|
||||
@@ -36358,6 +36724,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentConditionLink"
|
||||
encounterLinks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentEncounterLink"
|
||||
servingClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -36395,6 +36765,7 @@ components:
|
||||
- factCount
|
||||
- pendingCount
|
||||
- conditionLinks
|
||||
- encounterLinks
|
||||
- servingClass
|
||||
- hasContentIndex
|
||||
- contentIndexSource
|
||||
@@ -36424,6 +36795,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:
|
||||
@@ -36681,6 +37072,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentConditionLink"
|
||||
encounterLinks:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DocumentEncounterLink"
|
||||
servingClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -36738,6 +37133,7 @@ components:
|
||||
- factCount
|
||||
- pendingCount
|
||||
- conditionLinks
|
||||
- encounterLinks
|
||||
- servingClass
|
||||
- hasContentIndex
|
||||
- contentIndexSource
|
||||
@@ -38434,6 +38830,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 /
|
||||
@@ -38716,7 +39113,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
|
||||
@@ -38729,6 +39126,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."
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+137
-7
@@ -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?",
|
||||
@@ -3764,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": {
|
||||
@@ -5941,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.",
|
||||
@@ -8107,7 +8136,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",
|
||||
@@ -9261,7 +9291,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",
|
||||
@@ -9278,7 +9309,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",
|
||||
@@ -9571,6 +9603,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": {
|
||||
@@ -9666,7 +9703,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": {
|
||||
@@ -10027,6 +10065,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."
|
||||
}
|
||||
}
|
||||
+137
-7
@@ -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?",
|
||||
@@ -3764,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": {
|
||||
@@ -5941,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.",
|
||||
@@ -8107,7 +8136,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",
|
||||
@@ -9261,7 +9291,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",
|
||||
@@ -9278,7 +9309,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",
|
||||
@@ -9571,6 +9603,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": {
|
||||
@@ -9666,7 +9703,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": {
|
||||
@@ -10027,6 +10065,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."
|
||||
}
|
||||
}
|
||||
+137
-7
@@ -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?",
|
||||
@@ -3764,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": {
|
||||
@@ -5941,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.",
|
||||
@@ -8107,7 +8136,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",
|
||||
@@ -9261,7 +9291,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",
|
||||
@@ -9278,7 +9309,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",
|
||||
@@ -9571,6 +9603,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": {
|
||||
@@ -9666,7 +9703,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": {
|
||||
@@ -10027,6 +10065,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."
|
||||
}
|
||||
}
|
||||
+137
-7
@@ -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 ?",
|
||||
@@ -3764,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": {
|
||||
@@ -5941,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.",
|
||||
@@ -8107,7 +8136,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",
|
||||
@@ -9261,7 +9291,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",
|
||||
@@ -9278,7 +9309,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",
|
||||
@@ -9571,6 +9603,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": {
|
||||
@@ -9666,7 +9703,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": {
|
||||
@@ -10027,6 +10065,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."
|
||||
}
|
||||
}
|
||||
+137
-7
@@ -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?",
|
||||
@@ -3764,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": {
|
||||
@@ -5941,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.",
|
||||
@@ -8107,7 +8136,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",
|
||||
@@ -9261,7 +9291,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",
|
||||
@@ -9278,7 +9309,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",
|
||||
@@ -9571,6 +9603,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": {
|
||||
@@ -9666,7 +9703,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": {
|
||||
@@ -10027,6 +10065,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."
|
||||
}
|
||||
}
|
||||
+137
-7
@@ -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?",
|
||||
@@ -3764,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": {
|
||||
@@ -5941,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.",
|
||||
@@ -8107,7 +8136,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",
|
||||
@@ -9261,7 +9291,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ć",
|
||||
@@ -9278,7 +9309,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",
|
||||
@@ -9571,6 +9603,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": {
|
||||
@@ -9666,7 +9703,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": {
|
||||
@@ -10027,6 +10065,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;
|
||||
@@ -751,6 +751,7 @@ model User {
|
||||
googleHealthOAuthStates GoogleHealthOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
moodContexts MoodContext[]
|
||||
moodPredictions MoodPrediction[]
|
||||
mentalHealthAssessments MentalHealthAssessment[]
|
||||
moodTagsCustom MoodTag[] @relation("UserMoodTags")
|
||||
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
|
||||
@@ -4242,6 +4243,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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -381,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/practitioners/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.",
|
||||
@@ -1860,9 +1868,13 @@ 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.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 213;
|
||||
const FROZEN_ENTRY_COUNT = 215;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", async () => {
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
// The panel's visit offer reads the encounter list. Pending forever here:
|
||||
// this file is about the panel's own state, and a resolved read would put
|
||||
// an offer in the markup every assertion below would have to know about.
|
||||
useQuery: () => ({ data: undefined, isPending: true, isError: false }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -188,7 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(groupChip(html, "labs")).toBe("0/1");
|
||||
expect(groupChip(html, "medications")).toBe("0/4");
|
||||
expect(groupChip(html, "history")).toBe("0/2");
|
||||
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
|
||||
expect(groupChip(html, "history")).toBe("0/3");
|
||||
expect(groupChip(html, "cardio")).toBe("0/14");
|
||||
expect(groupChip(html, "activity")).toBe("0/8");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
|
||||
@@ -187,6 +187,7 @@ const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
|
||||
ecg_new_recording: "dashboard.heroItems.ecg_new_recording",
|
||||
tension_window: "dashboard.heroItems.tension_window",
|
||||
same_time_baseline: "dashboard.heroItems.same_time_baseline",
|
||||
upcoming_visit: "dashboard.heroItems.upcoming_visit",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The report's reason, pinned on the two properties that make it trustworthy:
|
||||
*
|
||||
* - it appears for an appointment inside the horizon and for nothing else;
|
||||
* - it NEVER auto-applies. Applying is a press, and the component calls
|
||||
* nothing on render — a window control that moves on its own is what makes
|
||||
* an export panel untrustworthy.
|
||||
*
|
||||
* The fixtures are relative to the moment the test runs, so the horizon cannot
|
||||
* rot out from under them as the calendar moves.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: 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(() => {}) };
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { ReportVisitOffer } from "../report-visit-offer";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: new Date(Date.now() + 5 * DAY_MS).toISOString(),
|
||||
status: "PLANNED",
|
||||
kind: "SPECIALIST",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(upcoming: EncounterDTO[], onUse = () => {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
{
|
||||
upcoming,
|
||||
past: [],
|
||||
},
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ReportVisitOffer onUseVisitDate={onUse} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ReportVisitOffer>", () => {
|
||||
it("offers an appointment five days out, naming its own source", () => {
|
||||
const html = render([visit({ id: "soon" })]);
|
||||
expect(html).toContain('data-slot="report-visit-offer"');
|
||||
expect(html).toContain("An appointment is coming up");
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
});
|
||||
|
||||
it("stays silent for an appointment thirty days out", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "far",
|
||||
occurredAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
|
||||
}),
|
||||
]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("stays silent when there is no appointment at all", () => {
|
||||
expect(render([])).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("ignores a cancelled appointment inside the horizon", () => {
|
||||
const html = render([visit({ id: "off", status: "CANCELLED" })]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("changes the window only when pressed", () => {
|
||||
const onUse = vi.fn();
|
||||
const html = render([visit({ id: "soon" })], onUse);
|
||||
// The offer rendered — so the assertion below is about restraint, not
|
||||
// about a component that produced nothing.
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
expect(onUse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Bericht für den Termin am … erstellen" — the report knows why it is being
|
||||
* generated.
|
||||
*
|
||||
* The cheapest integration in this milestone and the one that changes the most:
|
||||
* a person opening the export panel a week before an appointment is almost
|
||||
* always opening it BECAUSE of the appointment, and the window they want ends
|
||||
* on that day. One query, one prefilled field, one dismissible offer.
|
||||
*
|
||||
* **It never auto-applies.** The window is not silently changed and then left
|
||||
* for the person to discover; the offer names its own source ("weil am … ein
|
||||
* Termin ansteht") and does nothing until pressed. A control that moves on its
|
||||
* own is the thing that makes an export panel untrustworthy.
|
||||
*
|
||||
* Nothing about the stored selection changes: this sets the custom-range end
|
||||
* date and nothing else.
|
||||
*/
|
||||
import { CalendarClock, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* How far ahead a booked visit is treated as the reason for this report.
|
||||
*
|
||||
* Two weeks: far enough that somebody preparing in advance is offered it, near
|
||||
* enough that a routine check-up six months out does not colonise the panel.
|
||||
*/
|
||||
export const REPORT_VISIT_HORIZON_DAYS = 14;
|
||||
|
||||
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
|
||||
function localDay(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
const offset = at.getTimezoneOffset();
|
||||
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function ReportVisitOffer({
|
||||
onUseVisitDate,
|
||||
}: {
|
||||
/** Sets the custom window's END date. Called only on an explicit press. */
|
||||
onUseVisitDate: (endDay: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const visits = useEncounters();
|
||||
|
||||
// Read once, at mount. A clock read during render is impure — and here it
|
||||
// would also mean the offer could vanish mid-interaction as a visit crossed
|
||||
// the horizon between two renders.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const horizon = now + REPORT_VISIT_HORIZON_DAYS * 24 * 60 * 60 * 1000;
|
||||
const next = (visits.data?.upcoming ?? []).find((visit) => {
|
||||
if (visit.status !== "PLANNED") return false;
|
||||
const at = new Date(visit.occurredAt).getTime();
|
||||
return at > now && at <= horizon;
|
||||
});
|
||||
if (!next) return null;
|
||||
|
||||
const what =
|
||||
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="report-visit-offer"
|
||||
className="border-border flex flex-wrap items-center gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{/* Content, not meta: it is the offer, and it names its own source. */}
|
||||
<p className="text-foreground flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="report-visit-offer-apply"
|
||||
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
|
||||
>
|
||||
{t("settings.healthRecord.visitOfferApply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 sm:size-9"
|
||||
aria-label={t("common.dismiss")}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ReportVisitOffer } from "./report-visit-offer";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
@@ -253,6 +254,15 @@ export function HealthRecordExportPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Never auto-applies: pressing it is what opens the custom window and
|
||||
sets its end date. */}
|
||||
<ReportVisitOffer
|
||||
onUseVisitDate={(endDay) => {
|
||||
setCustomRange(true);
|
||||
setEndDate(endDay);
|
||||
}}
|
||||
/>
|
||||
|
||||
{customRange ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Data hooks for visits and the address book behind them.
|
||||
*
|
||||
* Every value the surfaces render arrives resolved from the server — the
|
||||
* practitioner as an object, the links with a label and a date, the
|
||||
* appointment's next-due instant computed. Nothing here re-derives one. A
|
||||
* client that recomputed a server-side derivation drifts from it the first
|
||||
* time the derivation changes on one side only.
|
||||
*
|
||||
* Writes reach two roots (`encounterDependentKeys`), because renaming a
|
||||
* practice changes the label every visit that names it renders.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
encounterDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import type { EncounterSuggestionResult } from "@/lib/encounters/suggest-window";
|
||||
|
||||
export type Encounter = EncounterDTO;
|
||||
export type EncounterList = EncounterListDTO;
|
||||
|
||||
export interface EncounterWriteBody {
|
||||
occurredAt?: string;
|
||||
status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
||||
kind?: string;
|
||||
practitionerId?: string | null;
|
||||
reason?: string | null;
|
||||
outcome?: string | null;
|
||||
/** The checkup this visit closes, when it was filed from a due one. */
|
||||
reminderId?: string | null;
|
||||
documentIds?: string[];
|
||||
labResultIds?: string[];
|
||||
episodeIds?: string[];
|
||||
}
|
||||
|
||||
const BASE = "/api/encounters";
|
||||
|
||||
export function useEncounters(
|
||||
enabled = true,
|
||||
window?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
status?: string;
|
||||
/** Only visits filed against this condition episode. */
|
||||
episodeId?: string;
|
||||
},
|
||||
) {
|
||||
const from = window?.from ?? null;
|
||||
const to = window?.to ?? null;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.encounterList(
|
||||
from,
|
||||
to,
|
||||
window?.status,
|
||||
window?.episodeId,
|
||||
),
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (from) sp.set("from", from);
|
||||
if (to) sp.set("to", to);
|
||||
if (window?.status) sp.set("status", window.status);
|
||||
if (window?.episodeId) sp.set("episodeId", window.episodeId);
|
||||
const qs = sp.toString();
|
||||
return apiGet<EncounterList>(qs ? `${BASE}?${qs}` : BASE);
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Does this belong to a visit?" for one anchor date.
|
||||
*
|
||||
* The verdict is resolved server-side so the document moment and the two lab
|
||||
* moments cannot answer the question differently. `anchor` being null means
|
||||
* the surface has no date yet and there is nothing to ask about.
|
||||
*/
|
||||
export function useEncounterSuggestion(anchor: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.encounterSuggestion(anchor ?? ""),
|
||||
queryFn: () =>
|
||||
apiGet<EncounterSuggestionResult>(
|
||||
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
|
||||
),
|
||||
enabled: enabled && Boolean(anchor),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEncounterMutations() {
|
||||
const qc = useQueryClient();
|
||||
/**
|
||||
* A visit write reaches further than the visit list.
|
||||
*
|
||||
* Filing one against a due checkup satisfies that reminder, which moves its
|
||||
* next-due date on the preventive-care list, on the dashboard card and on the
|
||||
* Today rail — three surfaces that read the reminder table and would
|
||||
* otherwise keep showing it due until their own poll. Booking one moves the
|
||||
* same reads the other way: a new appointment is a row the digest surfaces.
|
||||
*
|
||||
* So the fan-out is the visit roots PLUS the reminder reads, through the same
|
||||
* helper the Vorsorge surfaces use, rather than a second sweep that could
|
||||
* drift from it.
|
||||
*/
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
invalidateKeys(qc, encounterDependentKeys),
|
||||
invalidateReminderReads(qc),
|
||||
]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.encounterCreate(),
|
||||
mutationFn: (body: EncounterWriteBody) => apiPost<Encounter>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationKey: queryKeys.encounterUpdate(),
|
||||
mutationFn: ({ id, body }: { id: string; body: EncounterWriteBody }) =>
|
||||
apiPatch<Encounter>(`${BASE}/${id}`, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.encounterDelete(),
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Data hooks for the account's own address book of doctors and practices.
|
||||
*
|
||||
* The search runs in SQL over the two plaintext columns rather than in the
|
||||
* browser over a downloaded list: the columns are plaintext precisely so the
|
||||
* picker can search and sort them there, and a client-side filter would need
|
||||
* the whole book in memory to do it.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
encounterDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type { PractitionerDTO } from "@/lib/practitioners/dto";
|
||||
|
||||
export type Practitioner = PractitionerDTO;
|
||||
|
||||
export interface PractitionerWriteBody {
|
||||
name?: string;
|
||||
specialty?: string | null;
|
||||
practice?: string | null;
|
||||
location?: string | null;
|
||||
phone?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
const BASE = "/api/practitioners";
|
||||
|
||||
export function usePractitioners(q?: string, enabled = true) {
|
||||
const term = q?.trim() || undefined;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.practitionerList(term),
|
||||
queryFn: () =>
|
||||
apiGet<Practitioner[]>(
|
||||
term ? `${BASE}?q=${encodeURIComponent(term)}` : BASE,
|
||||
),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePractitionerMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => invalidateKeys(qc, encounterDependentKeys);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.practitionerCreate(),
|
||||
mutationFn: (body: PractitionerWriteBody) =>
|
||||
apiPost<Practitioner>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationKey: queryKeys.practitionerUpdate(),
|
||||
mutationFn: ({ id, body }: { id: string; body: PractitionerWriteBody }) =>
|
||||
apiPatch<Practitioner>(`${BASE}/${id}`, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.practitionerDelete(),
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* The matrix: what becomes a column, what never does, and what happens to a
|
||||
* day that is missing something.
|
||||
*
|
||||
* The cases that matter are the absences. A day with no context row must not
|
||||
* contribute a zero to a work-status column, because zero there means "worked
|
||||
* a regular day and not overtime" and the truth is "nobody said". That
|
||||
* distinction is invisible in the fitted numbers and would quietly teach the
|
||||
* model that every unlogged day was a day off.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_MODEL_FEATURES,
|
||||
MIN_FEATURE_COVERAGE,
|
||||
buildFeatureMatrix,
|
||||
extractDayFeatures,
|
||||
screenFeatures,
|
||||
standardiseDay,
|
||||
type PrognosisContextInput,
|
||||
type PrognosisDayInput,
|
||||
} from "../features";
|
||||
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||||
|
||||
const EMPTY_LINKED = {
|
||||
sleepAsleep: null,
|
||||
steps: null,
|
||||
activeEnergy: null,
|
||||
restingHeartRate: null,
|
||||
heartRateVariability: null,
|
||||
};
|
||||
|
||||
function context(
|
||||
overrides: Partial<PrognosisContextInput> = {},
|
||||
): PrognosisContextInput {
|
||||
return {
|
||||
workStatus: null,
|
||||
workMinutes: null,
|
||||
overtimeMinutes: null,
|
||||
workLoad: null,
|
||||
workSatisfaction: null,
|
||||
contactCircles: [],
|
||||
contactForm: null,
|
||||
contactExtent: null,
|
||||
contactQuality: null,
|
||||
contactSupport: null,
|
||||
leisureCategories: [],
|
||||
leisureMinutes: null,
|
||||
leisureJoy: null,
|
||||
leisureRecovery: null,
|
||||
eventType: null,
|
||||
eventValence: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function day(
|
||||
index: number,
|
||||
overrides: Partial<PrognosisDayInput> = {},
|
||||
): PrognosisDayInput {
|
||||
const date = new Date(Date.UTC(2026, 0, 1 + index));
|
||||
return {
|
||||
day: date.toISOString().slice(0, 10),
|
||||
a1: 5,
|
||||
dimensions: {},
|
||||
context: null,
|
||||
linked: { ...EMPTY_LINKED },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractDayFeatures", () => {
|
||||
it("takes A2 through the dimension table's inverse flag, not inline", () => {
|
||||
const a2 = MOOD_DIMENSIONS.find((d) => d.key === "a2");
|
||||
expect(a2?.inverse, "A2 is the inverse dimension this case is about").toBe(
|
||||
true,
|
||||
);
|
||||
const features = extractDayFeatures(
|
||||
day(0, { dimensions: { a2: 8, a3: 8 } }),
|
||||
);
|
||||
// Stress 8 is a heavy day, so on an up-is-better axis it reads as 2.
|
||||
expect(features.get("dimension:a2")).toBe(2);
|
||||
// A3 is not inverse and is carried literally.
|
||||
expect(features.get("dimension:a3")).toBe(8);
|
||||
});
|
||||
|
||||
it("never makes A1 an input", () => {
|
||||
const features = extractDayFeatures(
|
||||
day(0, { a1: 7, dimensions: { a1: 7, a2: 3 } }),
|
||||
);
|
||||
expect(features.has("dimension:a1")).toBe(false);
|
||||
});
|
||||
|
||||
it("a day with no context contributes no context column at all", () => {
|
||||
const features = extractDayFeatures(day(0, { context: null }));
|
||||
expect(
|
||||
[...features.keys()].filter((k) => k.startsWith("context:")),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("an unanswered field contributes no column; an answered one contributes zeros and a one", () => {
|
||||
const answered = extractDayFeatures(
|
||||
day(0, { context: context({ workStatus: "overtime" }) }),
|
||||
);
|
||||
expect(answered.get("context:workStatus=overtime")).toBe(1);
|
||||
expect(answered.get("context:workStatus=off")).toBe(0);
|
||||
|
||||
const unanswered = extractDayFeatures(
|
||||
day(0, { context: context({ workLoad: 4 }) }),
|
||||
);
|
||||
expect(unanswered.has("context:workStatus=overtime")).toBe(false);
|
||||
expect(unanswered.has("context:workStatus=off")).toBe(false);
|
||||
expect(unanswered.get("context:workLoad")).toBe(4);
|
||||
});
|
||||
|
||||
it("a recorded zero is a value, and absence is absence", () => {
|
||||
const zero = extractDayFeatures(
|
||||
day(0, { linked: { ...EMPTY_LINKED, steps: 0 } }),
|
||||
);
|
||||
expect(zero.get("linked:steps")).toBe(0);
|
||||
const absent = extractDayFeatures(day(0));
|
||||
expect(absent.has("linked:steps")).toBe(false);
|
||||
});
|
||||
|
||||
it("an empty multi-select says nothing rather than saying no to everything", () => {
|
||||
const features = extractDayFeatures(
|
||||
day(0, { context: context({ contactCircles: [] }) }),
|
||||
);
|
||||
expect(features.has("context:contactCircles=partner")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("screening", () => {
|
||||
/** A2 drives A1 exactly; everything else is constant or noise. */
|
||||
function drivenDays(count: number): PrognosisDayInput[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const stress = i % 11;
|
||||
return day(i, {
|
||||
// Up-is-better A2 is `10 - stress`, and the target follows it.
|
||||
a1: 10 - stress,
|
||||
dimensions: { a2: stress },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a feature that tracks the target", () => {
|
||||
const screened = screenFeatures(drivenDays(40));
|
||||
const a2 = screened.find((s) => s.key === "dimension:a2");
|
||||
expect(a2?.selected, "a perfectly tracking feature was screened out").toBe(
|
||||
true,
|
||||
);
|
||||
expect(a2?.pairs).toBe(40);
|
||||
});
|
||||
|
||||
it("refuses a feature recorded on too few of the days, before testing it", () => {
|
||||
const days = drivenDays(40);
|
||||
// Recorded on a quarter of the days — under the coverage floor whatever
|
||||
// it correlates with.
|
||||
const covered = Math.floor(40 * MIN_FEATURE_COVERAGE) - 5;
|
||||
for (let i = 0; i < covered; i++) {
|
||||
days[i] = { ...days[i], linked: { ...EMPTY_LINKED, steps: 1000 + i } };
|
||||
}
|
||||
const screened = screenFeatures(days);
|
||||
const steps = screened.find((s) => s.key === "linked:steps");
|
||||
expect(steps?.selected).toBe(false);
|
||||
expect(steps?.reason).toBe("coverage");
|
||||
});
|
||||
|
||||
it("refuses a one-hot seen fewer times than the occurrence floor", () => {
|
||||
const days = drivenDays(40).map((d, i) => ({
|
||||
...d,
|
||||
// Everybody worked; two days were overtime. The column is present on
|
||||
// every day, so coverage passes and the occurrence floor is what
|
||||
// refuses it.
|
||||
context: context({ workStatus: i < 2 ? "overtime" : "regular" }),
|
||||
}));
|
||||
const screened = screenFeatures(days);
|
||||
const overtime = screened.find(
|
||||
(s) => s.key === "context:workStatus=overtime",
|
||||
);
|
||||
expect(overtime?.selected).toBe(false);
|
||||
expect(overtime?.reason).toBe("occurrences");
|
||||
});
|
||||
|
||||
it("refuses pure noise — the whole reason the FDR layer is here", () => {
|
||||
// A deterministic pseudo-random series, so the case cannot flake.
|
||||
let seed = 42;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
const days = Array.from({ length: 60 }, (_, i) =>
|
||||
day(i, {
|
||||
a1: Math.round(random() * 10),
|
||||
dimensions: { a2: Math.round(random() * 10) },
|
||||
linked: { ...EMPTY_LINKED, steps: Math.round(random() * 10000) },
|
||||
}),
|
||||
);
|
||||
const selected = screenFeatures(days).filter((s) => s.selected);
|
||||
expect(selected.map((s) => s.key)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the column count", () => {
|
||||
expect(MAX_MODEL_FEATURES).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureMatrix", () => {
|
||||
function drivenDays(count: number): PrognosisDayInput[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
day(i, { a1: 10 - (i % 11), dimensions: { a2: i % 11 } }),
|
||||
);
|
||||
}
|
||||
|
||||
it("standardises per user, so the columns are that person's own units", () => {
|
||||
const matrix = buildFeatureMatrix(drivenDays(40));
|
||||
expect(matrix).not.toBeNull();
|
||||
if (!matrix) return;
|
||||
const column = matrix.rows.map((row) => row[0]);
|
||||
const mean = column.reduce((s, v) => s + v, 0) / column.length;
|
||||
const sd = Math.sqrt(
|
||||
column.reduce((s, v) => s + (v - mean) * (v - mean), 0) / column.length,
|
||||
);
|
||||
expect(mean).toBeCloseTo(0, 6);
|
||||
expect(sd).toBeCloseTo(1, 6);
|
||||
// The standardisation is kept so a later day can be scored on the same
|
||||
// scale rather than re-standardised against itself.
|
||||
expect(matrix.standardisation[0].key).toBe(matrix.features[0]);
|
||||
expect(matrix.standardisation[0].sd).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("orders rows oldest first, which is what makes the validation honest", () => {
|
||||
const matrix = buildFeatureMatrix(drivenDays(40));
|
||||
expect(matrix).not.toBeNull();
|
||||
if (!matrix) return;
|
||||
const sorted = [...matrix.days].sort();
|
||||
expect(matrix.days).toEqual(sorted);
|
||||
});
|
||||
|
||||
it("drops a day that is missing a selected feature rather than filling it in", () => {
|
||||
const days = drivenDays(40);
|
||||
days[5] = { ...days[5], dimensions: {} };
|
||||
const matrix = buildFeatureMatrix(days);
|
||||
expect(matrix).not.toBeNull();
|
||||
if (!matrix) return;
|
||||
expect(matrix.days).not.toContain(days[5].day);
|
||||
expect(matrix.rows).toHaveLength(39);
|
||||
// Nothing was imputed in its place.
|
||||
expect(matrix.targets).toHaveLength(39);
|
||||
});
|
||||
|
||||
it("refuses when nothing survives the screening", () => {
|
||||
const flat = Array.from({ length: 40 }, (_, i) => day(i, { a1: 5 }));
|
||||
expect(buildFeatureMatrix(flat)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a constant target instead of dividing by zero", () => {
|
||||
const constant = Array.from({ length: 40 }, (_, i) =>
|
||||
day(i, { a1: 5, dimensions: { a2: i % 7 } }),
|
||||
);
|
||||
expect(buildFeatureMatrix(constant)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("standardiseDay", () => {
|
||||
it("scores a day on the stored scale", () => {
|
||||
const row = standardiseDay(day(0, { dimensions: { a2: 5 } }), [
|
||||
{ key: "dimension:a2", mean: 5, sd: 2 },
|
||||
]);
|
||||
// Up-is-better A2 of a stress-5 day is 5, the mean, so zero.
|
||||
expect(row).toEqual([0]);
|
||||
});
|
||||
|
||||
it("refuses a day missing one of the fit's features", () => {
|
||||
expect(
|
||||
standardiseDay(day(0, { dimensions: {} }), [
|
||||
{ key: "dimension:a2", mean: 5, sd: 2 },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* `computeForecast` — the two floors a fit has to clear, and the refusals.
|
||||
*
|
||||
* The first floor is the ladder, on rated days. The second is the one this
|
||||
* file exists for: a complete-case matrix can shrink well below the rated-day
|
||||
* count, because a day missing one of the fit's inputs is dropped rather than
|
||||
* imputed. An account with thirty rated days but a feature recorded on only
|
||||
* two thirds of them fits on twenty complete rows, and a forecast "built from
|
||||
* twenty days" past a floor that was supposed to mean thirty is a floor that
|
||||
* does not hold. The fit is refused below `MIN_ENTRIES_FOR_FORECAST` complete
|
||||
* rows, not only below thirty rated days.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeForecast } from "../forecast";
|
||||
import type { PrognosisDayInput } from "../features";
|
||||
import { MIN_ENTRIES_FOR_FORECAST } from "../thresholds";
|
||||
|
||||
const EMPTY_LINKED = {
|
||||
sleepAsleep: null,
|
||||
steps: null,
|
||||
activeEnergy: null,
|
||||
restingHeartRate: null,
|
||||
heartRateVariability: null,
|
||||
};
|
||||
|
||||
function dayKey(index: number): string {
|
||||
return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* `rated` days carrying a rating driven by A2, of which the most recent
|
||||
* `withFeature` actually carry the A2 slider. The rest are older face-only
|
||||
* days: rated, but with no input the fit can use, so they drop out of the
|
||||
* complete-case matrix. The feature sits on the RECENT days on purpose, so the
|
||||
* days the write window would score are scorable — otherwise the refusal is
|
||||
* "no recent day to score" rather than the "too few complete rows" this file
|
||||
* is about.
|
||||
*/
|
||||
function sparseDays(rated: number, withFeature: number): PrognosisDayInput[] {
|
||||
return Array.from({ length: rated }, (_, i) => {
|
||||
const stress = i % 11;
|
||||
const hasFeature = i >= rated - withFeature;
|
||||
return {
|
||||
day: dayKey(i),
|
||||
a1: Math.round(0.8 * (10 - stress) + 1),
|
||||
dimensions: hasFeature ? { a2: stress } : {},
|
||||
context: null,
|
||||
linked: { ...EMPTY_LINKED },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("computeForecast complete-row floor", () => {
|
||||
it("refuses a fit that clears the rated-day floor but not the complete-row one", () => {
|
||||
// 40 rated days, A2 on 25 of them: coverage 62.5% clears the candidate
|
||||
// floor, so A2 is screened in, but the complete-case matrix is 25 rows —
|
||||
// under the 30-row floor.
|
||||
const result = computeForecast(sparseDays(40, 25));
|
||||
expect(result.status, JSON.stringify(result)).toBe("refused");
|
||||
});
|
||||
|
||||
it("writes once the complete rows themselves reach the floor", () => {
|
||||
// Every rated day carries the feature, so complete rows equal rated days.
|
||||
const result = computeForecast(sparseDays(40, 40));
|
||||
expect(result.status).toBe("written");
|
||||
if (result.status === "written") {
|
||||
expect(result.n).toBeGreaterThanOrEqual(MIN_ENTRIES_FOR_FORECAST);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* The minimum-n ladder, at every boundary, from both sides.
|
||||
*
|
||||
* Each case names the threshold it is about, so a red here says which floor
|
||||
* moved rather than "expected false to be true". That matters more than usual:
|
||||
* the thing these gates prevent is invisible on screen, so the test output is
|
||||
* the only place the failure is ever legible.
|
||||
*
|
||||
* Every one of these has been watched failing — the comparison flipped from
|
||||
* `>=` to `>`, the red read, the file restored and the hash checked — and one
|
||||
* check was deleted outright to prove the absence of a gate fails too. The log
|
||||
* is in the phase's execution notes.
|
||||
*
|
||||
* The assertions name the REASON, not just the absence: "no forecast" and "no
|
||||
* forecast because there are only eleven entries and fifteen is where anything
|
||||
* starts" are different answers, and only the second one can be shown to
|
||||
* somebody.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MIN_ENTRIES_FOR_FORECAST,
|
||||
MIN_ENTRIES_FOR_REGULAR_FORECAST,
|
||||
MIN_ENTRIES_FOR_SEASONAL,
|
||||
MIN_ENTRIES_LEARNING_PHASE,
|
||||
MIN_TAG_OCCURRENCES,
|
||||
forecastGate,
|
||||
occurrencesSuffice,
|
||||
prognosisStage,
|
||||
seasonalComparisonsUnlocked,
|
||||
} from "../thresholds";
|
||||
import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
|
||||
|
||||
describe("the ladder is ordered and its rungs are the documented numbers", () => {
|
||||
it("holds the values the concept asks for", () => {
|
||||
expect(MIN_ENTRIES_LEARNING_PHASE).toBe(15);
|
||||
expect(MIN_ENTRIES_FOR_FORECAST).toBe(30);
|
||||
expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBe(60);
|
||||
expect(MIN_ENTRIES_FOR_SEASONAL).toBe(90);
|
||||
});
|
||||
|
||||
it("rises strictly, so no rung can be reached before the one below it", () => {
|
||||
expect(MIN_ENTRIES_LEARNING_PHASE).toBeLessThan(MIN_ENTRIES_FOR_FORECAST);
|
||||
expect(MIN_ENTRIES_FOR_FORECAST).toBeLessThan(
|
||||
MIN_ENTRIES_FOR_REGULAR_FORECAST,
|
||||
);
|
||||
expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBeLessThan(
|
||||
MIN_ENTRIES_FOR_SEASONAL,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_ENTRIES_LEARNING_PHASE — below it, nothing at all", () => {
|
||||
it(`refuses at ${MIN_ENTRIES_LEARNING_PHASE - 1}, and says why`, () => {
|
||||
const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE - 1);
|
||||
expect(gate.present, "MIN_ENTRIES_LEARNING_PHASE let n-1 through").toBe(
|
||||
false,
|
||||
);
|
||||
expect(gate).toEqual({
|
||||
present: false,
|
||||
reason: "no-output-yet",
|
||||
stage: "insufficient",
|
||||
entries: MIN_ENTRIES_LEARNING_PHASE - 1,
|
||||
nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
|
||||
});
|
||||
});
|
||||
|
||||
it(`reaches the learning phase at exactly ${MIN_ENTRIES_LEARNING_PHASE}`, () => {
|
||||
const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE);
|
||||
expect(
|
||||
gate,
|
||||
"MIN_ENTRIES_LEARNING_PHASE did not admit its own value",
|
||||
).toEqual({
|
||||
present: false,
|
||||
reason: "learning-phase",
|
||||
stage: "learning",
|
||||
entries: MIN_ENTRIES_LEARNING_PHASE,
|
||||
nextThreshold: MIN_ENTRIES_FOR_FORECAST,
|
||||
});
|
||||
});
|
||||
|
||||
it("an empty account is absence, never a zero", () => {
|
||||
const gate = forecastGate(0);
|
||||
expect(gate.present).toBe(false);
|
||||
expect(gate).not.toHaveProperty("predicted");
|
||||
if (!gate.present) expect(gate.entries).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_ENTRIES_FOR_FORECAST — below it, no forecast", () => {
|
||||
it(`refuses at ${MIN_ENTRIES_FOR_FORECAST - 1}, naming the learning phase`, () => {
|
||||
const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST - 1);
|
||||
expect(gate.present, "MIN_ENTRIES_FOR_FORECAST let n-1 through").toBe(
|
||||
false,
|
||||
);
|
||||
if (!gate.present) {
|
||||
expect(gate.reason).toBe("learning-phase");
|
||||
expect(gate.nextThreshold).toBe(MIN_ENTRIES_FOR_FORECAST);
|
||||
expect(gate.entries).toBe(MIN_ENTRIES_FOR_FORECAST - 1);
|
||||
}
|
||||
});
|
||||
|
||||
it(`forecasts at exactly ${MIN_ENTRIES_FOR_FORECAST}, labelled provisional`, () => {
|
||||
const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST);
|
||||
expect(gate.present, "MIN_ENTRIES_FOR_FORECAST refused its own value").toBe(
|
||||
true,
|
||||
);
|
||||
if (gate.present) {
|
||||
expect(gate.stage).toBe("provisional");
|
||||
expect(gate.provisional).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_ENTRIES_FOR_REGULAR_FORECAST — below it, provisional", () => {
|
||||
it(`is still provisional at ${MIN_ENTRIES_FOR_REGULAR_FORECAST - 1}`, () => {
|
||||
const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST - 1);
|
||||
expect(gate.present).toBe(true);
|
||||
if (gate.present) {
|
||||
expect(
|
||||
gate.provisional,
|
||||
"MIN_ENTRIES_FOR_REGULAR_FORECAST dropped the provisional label at n-1",
|
||||
).toBe(true);
|
||||
expect(gate.stage).toBe("provisional");
|
||||
}
|
||||
});
|
||||
|
||||
it(`drops the label at exactly ${MIN_ENTRIES_FOR_REGULAR_FORECAST}`, () => {
|
||||
const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST);
|
||||
expect(gate.present).toBe(true);
|
||||
if (gate.present) {
|
||||
expect(
|
||||
gate.provisional,
|
||||
"MIN_ENTRIES_FOR_REGULAR_FORECAST kept the provisional label at its own value",
|
||||
).toBe(false);
|
||||
expect(gate.stage).toBe("regular");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_ENTRIES_FOR_SEASONAL — below it, no weekday or seasonal view", () => {
|
||||
it(`refuses the comparison at ${MIN_ENTRIES_FOR_SEASONAL - 1}`, () => {
|
||||
expect(
|
||||
seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL - 1),
|
||||
"MIN_ENTRIES_FOR_SEASONAL unlocked the comparison at n-1",
|
||||
).toBe(false);
|
||||
expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL - 1)).toBe("regular");
|
||||
});
|
||||
|
||||
it(`unlocks it at exactly ${MIN_ENTRIES_FOR_SEASONAL}`, () => {
|
||||
expect(
|
||||
seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL),
|
||||
"MIN_ENTRIES_FOR_SEASONAL refused its own value",
|
||||
).toBe(true);
|
||||
expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL)).toBe("seasonal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_TAG_OCCURRENCES — a value seen four times is not evidence", () => {
|
||||
it(`refuses at ${MIN_TAG_OCCURRENCES - 1}`, () => {
|
||||
expect(
|
||||
occurrencesSuffice(MIN_TAG_OCCURRENCES - 1),
|
||||
"MIN_TAG_OCCURRENCES admitted a value seen n-1 times",
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it(`admits at exactly ${MIN_TAG_OCCURRENCES}`, () => {
|
||||
expect(
|
||||
occurrencesSuffice(MIN_TAG_OCCURRENCES),
|
||||
"MIN_TAG_OCCURRENCES refused its own value",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is the crosstab's floor rather than a second opinion about it", () => {
|
||||
expect(MIN_TAG_OCCURRENCES).toBe(CONTEXT_MIN_PRESENT_DAYS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no second number is minted for an idea that already has one", () => {
|
||||
const THRESHOLDS = readFileSync(join(__dirname, "../thresholds.ts"), "utf8");
|
||||
|
||||
it("the file really is the one under test", () => {
|
||||
// The positive control: the two checks below pass on an empty read, so
|
||||
// each needs proof the file was found and holds what it claims to.
|
||||
expect(THRESHOLDS.length).toBeGreaterThan(1000);
|
||||
expect(THRESHOLDS).toContain("MIN_ENTRIES_FOR_SEASONAL");
|
||||
});
|
||||
|
||||
it("declares no paired-observation floor of its own", () => {
|
||||
// `MIN_PAIRED_N` lives in `correlations.ts` and the screening imports it
|
||||
// from there. A constant here named for the same idea would be a second
|
||||
// answer to one question, and the two would drift.
|
||||
expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRED/);
|
||||
expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRS/);
|
||||
});
|
||||
|
||||
it("takes the occurrence floor from the crosstab rather than restating it", () => {
|
||||
expect(THRESHOLDS).toMatch(
|
||||
/MIN_TAG_OCCURRENCES\s*=\s*CONTEXT_MIN_PRESENT_DAYS/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* The fit, and the one property that makes its band worth reading.
|
||||
*
|
||||
* The leakage case is the reason this file exists. A random k-fold over days
|
||||
* produces a model that scores beautifully and predicts nothing, and there is
|
||||
* nothing in the reported error that says so — which is why the assertion is
|
||||
* on the FOLD INDICES and on the day keys themselves rather than on the error.
|
||||
* An error-based check passes a leaking split whenever the leak happens to be
|
||||
* small, which is exactly when nobody would look.
|
||||
*
|
||||
* Watched red: the split was replaced with a shuffle, and
|
||||
* `describe("no training fold contains a day after its validation fold")`
|
||||
* went red naming a future day inside a training fold. Reverted, hash checked.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildFeatureMatrix, type PrognosisDayInput } from "../features";
|
||||
import { fitRidge, predictWithFit, solveLinearSystem } from "../ridge";
|
||||
import {
|
||||
BAND_LOWER_QUANTILE,
|
||||
BAND_UPPER_QUANTILE,
|
||||
LAMBDA_GRID,
|
||||
MIN_VALIDATION_ROWS,
|
||||
MODEL_VERSION,
|
||||
fitPrognosisModel,
|
||||
quantile,
|
||||
rollingOriginFolds,
|
||||
} from "../validate";
|
||||
|
||||
const EMPTY_LINKED = {
|
||||
sleepAsleep: null,
|
||||
steps: null,
|
||||
activeEnergy: null,
|
||||
restingHeartRate: null,
|
||||
heartRateVariability: null,
|
||||
};
|
||||
|
||||
function dayKey(index: number): string {
|
||||
return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** A deterministic pseudo-random series — a flaky statistics test is useless. */
|
||||
function seeded(seed: number): () => number {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1103515245 + 12345) % 2147483648;
|
||||
return state / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Days whose target is a clean linear function of one dimension.
|
||||
*
|
||||
* One driver rather than two, and that is a statement about the screening
|
||||
* rather than about the fit: the screening is univariate, so a feature that
|
||||
* only matters jointly with another is refused before the fit ever sees it.
|
||||
* A two-driver fixture measures that property, not this one.
|
||||
*/
|
||||
function linearDays(count: number): PrognosisDayInput[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const stress = i % 11;
|
||||
return {
|
||||
day: dayKey(i),
|
||||
// 0.8 × up-is-better stress, plus one — inside the 0-10 scale.
|
||||
a1: 0.8 * (10 - stress) + 1,
|
||||
dimensions: { a2: stress, a3: (i * 7) % 11 },
|
||||
context: null,
|
||||
linked: { ...EMPTY_LINKED },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Days whose target is noise and whose inputs are unrelated noise. */
|
||||
function noisyDays(count: number, seed = 7): PrognosisDayInput[] {
|
||||
const random = seeded(seed);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
day: dayKey(i),
|
||||
a1: Math.round(random() * 10),
|
||||
dimensions: {
|
||||
a2: Math.round(random() * 10),
|
||||
a3: Math.round(random() * 10),
|
||||
},
|
||||
context: null,
|
||||
linked: { ...EMPTY_LINKED },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("no training fold contains a day after its validation fold", () => {
|
||||
const ROWS = 60;
|
||||
const folds = rollingOriginFolds(ROWS);
|
||||
|
||||
it("produces folds at all — the assertions below pass on an empty list", () => {
|
||||
expect(folds.length).toBeGreaterThan(0);
|
||||
expect(folds.length).toBeLessThanOrEqual(4);
|
||||
// Every validation row is covered exactly once and the last fold reaches
|
||||
// the end, so no day is silently dropped from the measurement.
|
||||
expect(folds[folds.length - 1].validationEnd).toBe(ROWS);
|
||||
});
|
||||
|
||||
it("trains on a prefix that ends exactly where the validation starts", () => {
|
||||
for (const fold of folds) {
|
||||
expect(fold.trainEnd).toBe(fold.validationStart);
|
||||
expect(fold.trainEnd).toBeGreaterThan(0);
|
||||
expect(fold.validationEnd).toBeGreaterThan(fold.validationStart);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds on the day keys, not just on the indices", () => {
|
||||
// The indices are only meaningful because the matrix is time-ordered, so
|
||||
// the property is asserted against the dates the rows actually carry.
|
||||
const matrix = buildFeatureMatrix(linearDays(ROWS));
|
||||
expect(matrix).not.toBeNull();
|
||||
if (!matrix) return;
|
||||
|
||||
for (const fold of rollingOriginFolds(matrix.rows.length)) {
|
||||
const trainingDays = matrix.days.slice(0, fold.trainEnd);
|
||||
const validationDays = matrix.days.slice(
|
||||
fold.validationStart,
|
||||
fold.validationEnd,
|
||||
);
|
||||
const latestTrainingDay = trainingDays[trainingDays.length - 1];
|
||||
const earliestValidationDay = validationDays[0];
|
||||
const leaked = trainingDays.filter((d) => d >= earliestValidationDay);
|
||||
expect(
|
||||
leaked,
|
||||
`a training fold holds ${leaked.length} day(s) dated at or after the validation fold that starts on ${earliestValidationDay}`,
|
||||
).toEqual([]);
|
||||
expect(latestTrainingDay < earliestValidationDay).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("expands rather than slides — later folds train on more history", () => {
|
||||
const sizes = folds.map((f) => f.trainEnd);
|
||||
for (let i = 1; i < sizes.length; i++) {
|
||||
expect(sizes[i]).toBeGreaterThan(sizes[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to split a window too short to measure anything on", () => {
|
||||
expect(rollingOriginFolds(MIN_VALIDATION_ROWS - 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ridge solve", () => {
|
||||
it("recovers the coefficients of a clean linear signal", () => {
|
||||
// y = 2*x1 - 3*x2, standardised columns, tiny penalty.
|
||||
const rows: number[][] = [];
|
||||
const targets: number[] = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const x1 = ((i % 7) - 3) / 2;
|
||||
const x2 = ((i % 5) - 2) / 1.5;
|
||||
rows.push([x1, x2]);
|
||||
targets.push(2 * x1 - 3 * x2 + 5);
|
||||
}
|
||||
const fit = fitRidge(rows, targets, 1e-6);
|
||||
expect(fit).not.toBeNull();
|
||||
if (!fit) return;
|
||||
expect(fit.coefficients[0]).toBeCloseTo(2, 2);
|
||||
expect(fit.coefficients[1]).toBeCloseTo(-3, 2);
|
||||
// The intercept is the mean of the training targets, and the columns are
|
||||
// centred against their own means, so a row at the column means predicts
|
||||
// the average day exactly.
|
||||
expect(predictWithFit(fit, fit.columnMeans)).toBeCloseTo(fit.intercept, 6);
|
||||
// And every training row is predicted back to its own target.
|
||||
rows.forEach((row, i) => {
|
||||
expect(predictWithFit(fit, row)).toBeCloseTo(targets[i], 2);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not divide by zero on a constant target", () => {
|
||||
const rows = Array.from({ length: 20 }, (_, i) => [i / 10, (i % 3) / 2]);
|
||||
const fit = fitRidge(rows, new Array(20).fill(4), 1);
|
||||
expect(fit).not.toBeNull();
|
||||
if (!fit) return;
|
||||
expect(fit.coefficients.every(Number.isFinite)).toBe(true);
|
||||
expect(fit.intercept).toBe(4);
|
||||
expect(predictWithFit(fit, [0, 0])).toBeCloseTo(4, 6);
|
||||
});
|
||||
|
||||
it("refuses a singular system rather than answering zeros", () => {
|
||||
// Two identical columns and no penalty: the system has no unique answer,
|
||||
// and a vector of zeros would look exactly like "these features do not
|
||||
// matter".
|
||||
expect(
|
||||
solveLinearSystem(
|
||||
[
|
||||
[1, 1],
|
||||
[1, 1],
|
||||
],
|
||||
[1, 1],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("penalises: a larger lambda pulls the coefficients towards zero", () => {
|
||||
const rows = Array.from({ length: 30 }, (_, i) => [((i % 7) - 3) / 2]);
|
||||
const targets = rows.map((r) => 4 * r[0] + 5);
|
||||
const light = fitRidge(rows, targets, 0.01);
|
||||
const heavy = fitRidge(rows, targets, 100);
|
||||
expect(light).not.toBeNull();
|
||||
expect(heavy).not.toBeNull();
|
||||
if (!light || !heavy) return;
|
||||
expect(Math.abs(heavy.coefficients[0])).toBeLessThan(
|
||||
Math.abs(light.coefficients[0]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the band reacts to how wrong the model is", () => {
|
||||
it("is narrow on a clean signal and wide on noise", () => {
|
||||
const clean = buildFeatureMatrix(linearDays(80));
|
||||
expect(clean).not.toBeNull();
|
||||
if (!clean) return;
|
||||
const cleanModel = fitPrognosisModel(clean);
|
||||
expect(cleanModel).not.toBeNull();
|
||||
if (!cleanModel) return;
|
||||
const cleanWidth = cleanModel.bandHigh - cleanModel.bandLow;
|
||||
|
||||
// Noise cannot go through the screening — it is refused before a fit is
|
||||
// attempted, which is the stronger answer. So the band's reaction is
|
||||
// measured on the matrix directly, with the screening bypassed by handing
|
||||
// the fit a matrix built from the noisy targets and the clean columns.
|
||||
const noisy = buildFeatureMatrix(linearDays(80));
|
||||
expect(noisy).not.toBeNull();
|
||||
if (!noisy) return;
|
||||
const random = seeded(11);
|
||||
const noisyMatrix = {
|
||||
...noisy,
|
||||
targets: noisy.targets.map(() => Math.round(random() * 10)),
|
||||
};
|
||||
const noisyModel = fitPrognosisModel(noisyMatrix);
|
||||
expect(noisyModel).not.toBeNull();
|
||||
if (!noisyModel) return;
|
||||
const noisyWidth = noisyModel.bandHigh - noisyModel.bandLow;
|
||||
|
||||
expect(
|
||||
noisyWidth,
|
||||
"the band did not widen on noise — a confident wrong number is the failure this band exists to prevent",
|
||||
).toBeGreaterThan(cleanWidth * 2);
|
||||
expect(cleanWidth).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it("refuses noise at the screening, before any fit is attempted", () => {
|
||||
expect(buildFeatureMatrix(noisyDays(80))).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the band from the validation residuals, at the stated quantiles", () => {
|
||||
const matrix = buildFeatureMatrix(linearDays(80));
|
||||
expect(matrix).not.toBeNull();
|
||||
if (!matrix) return;
|
||||
const model = fitPrognosisModel(matrix);
|
||||
expect(model).not.toBeNull();
|
||||
if (!model) return;
|
||||
expect(model.bandLow).toBeLessThanOrEqual(model.bandHigh);
|
||||
expect(BAND_LOWER_QUANTILE).toBe(0.1);
|
||||
expect(BAND_UPPER_QUANTILE).toBe(0.9);
|
||||
expect(model.n).toBe(matrix.rows.length);
|
||||
expect(model.modelVersion).toBe(MODEL_VERSION);
|
||||
expect(LAMBDA_GRID).toContain(model.fit.lambda);
|
||||
// Never zero: an unpenalised solve memorises the training days.
|
||||
expect(Math.min(...LAMBDA_GRID)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("quantile interpolates rather than rounding to a sample", () => {
|
||||
expect(quantile([0, 10], 0.5)).toBe(5);
|
||||
expect(quantile([1, 2, 3, 4], 0)).toBe(1);
|
||||
expect(quantile([1, 2, 3, 4], 1)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The names the model's inputs are known by, everywhere.
|
||||
*
|
||||
* One string per feature, stored in `MoodPrediction.features`, read back by
|
||||
* the surface that names what the model weighted, and compared against by the
|
||||
* tests. It is therefore a wire format in all but name: renaming a key
|
||||
* silently orphans every stored row that used it, which is why the encoding is
|
||||
* a function here rather than a template literal at four call sites.
|
||||
*
|
||||
* The shape is `<family>:<rest>`, and the four families are the four places an
|
||||
* input can come from:
|
||||
*
|
||||
* * `dimension:a2` — a level-A self-rating (A1 is the target)
|
||||
* * `context:workLoad` — a numeric field of the day's context
|
||||
* * `context:workStatus=off` — one value of a closed context vocabulary
|
||||
* * `linked:steps` — a figure read from the module that owns it
|
||||
*
|
||||
* Nothing here resolves a label. The label is a locale lookup and this module
|
||||
* is pure and bundle-safe, so the resolver lives beside the component that
|
||||
* renders it and takes a parsed key from here.
|
||||
*/
|
||||
import type { MoodDimensionKey } from "@/lib/mood/dimensions";
|
||||
|
||||
/** The linked figures the model may read, and the day-figure each names. */
|
||||
export const LINKED_FEATURE_METRICS = [
|
||||
"sleepAsleep",
|
||||
"steps",
|
||||
"activeEnergy",
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
] as const;
|
||||
|
||||
export type LinkedFeatureMetric = (typeof LINKED_FEATURE_METRICS)[number];
|
||||
|
||||
export type ParsedFeatureKey =
|
||||
| { family: "dimension"; dimension: MoodDimensionKey }
|
||||
| { family: "context"; field: string; value: null }
|
||||
| { family: "context"; field: string; value: string }
|
||||
| { family: "linked"; metric: LinkedFeatureMetric };
|
||||
|
||||
/** `dimension:a2` */
|
||||
export function dimensionFeatureKey(dimension: MoodDimensionKey): string {
|
||||
return `dimension:${dimension}`;
|
||||
}
|
||||
|
||||
/** `context:workLoad` — a numeric field. */
|
||||
export function contextNumericFeatureKey(field: string): string {
|
||||
return `context:${field}`;
|
||||
}
|
||||
|
||||
/** `context:workStatus=off` — one value of a closed vocabulary. */
|
||||
export function contextValueFeatureKey(field: string, value: string): string {
|
||||
return `context:${field}=${value}`;
|
||||
}
|
||||
|
||||
/** `linked:steps` */
|
||||
export function linkedFeatureKey(metric: LinkedFeatureMetric): string {
|
||||
return `linked:${metric}`;
|
||||
}
|
||||
|
||||
const DIMENSION_KEYS = new Set(["a1", "a2", "a3", "a4", "a5"]);
|
||||
const LINKED_KEYS = new Set<string>(LINKED_FEATURE_METRICS);
|
||||
|
||||
/**
|
||||
* Read a stored key back.
|
||||
*
|
||||
* `null` for anything this version does not recognise, and that case is real
|
||||
* rather than defensive: a row written by an older `modelVersion` can name a
|
||||
* feature the current build has dropped, and the surface has to skip it rather
|
||||
* than render `undefined` at somebody.
|
||||
*/
|
||||
export function parseFeatureKey(key: string): ParsedFeatureKey | null {
|
||||
const separator = key.indexOf(":");
|
||||
if (separator < 0) return null;
|
||||
const family = key.slice(0, separator);
|
||||
const rest = key.slice(separator + 1);
|
||||
if (rest.length === 0) return null;
|
||||
|
||||
if (family === "dimension") {
|
||||
return DIMENSION_KEYS.has(rest)
|
||||
? { family: "dimension", dimension: rest as MoodDimensionKey }
|
||||
: null;
|
||||
}
|
||||
if (family === "linked") {
|
||||
return LINKED_KEYS.has(rest)
|
||||
? { family: "linked", metric: rest as LinkedFeatureMetric }
|
||||
: null;
|
||||
}
|
||||
if (family === "context") {
|
||||
const equals = rest.indexOf("=");
|
||||
if (equals < 0) return { family: "context", field: rest, value: null };
|
||||
const field = rest.slice(0, equals);
|
||||
const value = rest.slice(equals + 1);
|
||||
if (field.length === 0 || value.length === 0) return null;
|
||||
return { family: "context", field, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* One row per day, one column per thing the day was made of.
|
||||
*
|
||||
* The target is the day's own pleasantness (A1) and everything else is an
|
||||
* input: the other four level-A dimensions, the numbers and the closed
|
||||
* vocabularies of the day's context, and the figures the sleep, activity and
|
||||
* recovery modules already hold for that day. A1 is the only target on
|
||||
* purpose — five targets would be five ways to be wrong and five bands to
|
||||
* explain.
|
||||
*
|
||||
* Four rules this file is built on, each of which has a way of being got
|
||||
* wrong that looks fine on screen:
|
||||
*
|
||||
* 1. **Nothing is imputed. Ever.** A day with a missing input is not a day
|
||||
* with an average input. The stated rule is complete cases: a day enters
|
||||
* the fit only if it carries the target and every SELECTED feature, and a
|
||||
* feature only becomes a candidate if it was recorded on at least
|
||||
* {@link MIN_FEATURE_COVERAGE} of the days that carry a target. The
|
||||
* coverage floor is what keeps the complete-case rule from quietly
|
||||
* emptying the matrix: without it, one field somebody filled in twice
|
||||
* would exclude every other day they ever logged.
|
||||
* 2. **Standardised per user, not min-max.** A person whose stress never
|
||||
* exceeds six must not have six read as "maximum stress". The mean and the
|
||||
* spread come from their own fit window, so every feature is expressed in
|
||||
* that person's own units — which is also what makes the stored
|
||||
* contributions comparable to each other.
|
||||
* 3. **Screened before it is fitted, with the multiple-comparison correction
|
||||
* the rest of the product already runs.** A sweep over sixty context
|
||||
* features at raw p < 0.05 finds three things that are not there. Pearson
|
||||
* with the shared `MIN_PAIRED_N` floor, Benjamini-Hochberg at the shared
|
||||
* `FDR_Q`, and the shared effect-size floor on the shrunk estimate — all
|
||||
* imported, none re-tuned here.
|
||||
* 4. **Orientation comes from the table that owns it.** A2 is inverse and is
|
||||
* flipped through `MOOD_DIMENSIONS[i].inverse`, never inline, so the chart
|
||||
* and the model read one source. Context ratings are NOT flipped: no table
|
||||
* states their orientation, and inventing one here would be exactly the
|
||||
* second opinion the dimension table exists to prevent. It costs nothing
|
||||
* mathematically — a flip only changes the sign of a coefficient, and the
|
||||
* stored contribution is signed anyway.
|
||||
*/
|
||||
import {
|
||||
EFFECT_SIZE_FLOOR,
|
||||
FDR_Q,
|
||||
benjaminiHochberg,
|
||||
shrinkEstimate,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import {
|
||||
MAX_P_VALUE,
|
||||
MIN_PAIRED_N,
|
||||
pearson,
|
||||
} from "@/lib/insights/correlations";
|
||||
import {
|
||||
CONTACT_CIRCLE_KEYS,
|
||||
CONTACT_EXTENT_KEYS,
|
||||
CONTACT_FORM_KEYS,
|
||||
EVENT_TYPE_KEYS,
|
||||
LEISURE_CATEGORY_KEYS,
|
||||
WORK_STATUS_KEYS,
|
||||
} from "@/lib/mood/context-vocabulary";
|
||||
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||||
import type { MoodDimensionKey } from "@/lib/mood/dimensions";
|
||||
|
||||
import {
|
||||
contextNumericFeatureKey,
|
||||
contextValueFeatureKey,
|
||||
dimensionFeatureKey,
|
||||
linkedFeatureKey,
|
||||
LINKED_FEATURE_METRICS,
|
||||
} from "./feature-keys";
|
||||
import { occurrencesSuffice } from "./thresholds";
|
||||
|
||||
/**
|
||||
* Most features the fit may carry.
|
||||
*
|
||||
* Twelve over thirty days is already generous — a ridge penalty is what makes
|
||||
* that survivable at all — and the cap is what stops a well-logged account
|
||||
* from being fitted with sixty columns and a hundred rows.
|
||||
*/
|
||||
export const MAX_MODEL_FEATURES = 12;
|
||||
|
||||
/**
|
||||
* Share of target-carrying days a feature must be recorded on to be a
|
||||
* candidate at all.
|
||||
*
|
||||
* The other half of the complete-case rule. A field somebody filled in for a
|
||||
* fortnight and then stopped is not a column of the matrix; it is a fortnight,
|
||||
* and admitting it would silently discard every day outside that fortnight.
|
||||
*/
|
||||
export const MIN_FEATURE_COVERAGE = 0.6;
|
||||
|
||||
/** The day's context as the model reads it. The note never comes here. */
|
||||
export interface PrognosisContextInput {
|
||||
workStatus: string | null;
|
||||
workMinutes: number | null;
|
||||
overtimeMinutes: number | null;
|
||||
workLoad: number | null;
|
||||
workSatisfaction: number | null;
|
||||
contactCircles: string[];
|
||||
contactForm: string | null;
|
||||
contactExtent: string | null;
|
||||
contactQuality: number | null;
|
||||
contactSupport: number | null;
|
||||
leisureCategories: string[];
|
||||
leisureMinutes: number | null;
|
||||
leisureJoy: number | null;
|
||||
leisureRecovery: number | null;
|
||||
eventType: string | null;
|
||||
eventValence: number | null;
|
||||
}
|
||||
|
||||
/** One day, as everything upstream of the fit has resolved it. */
|
||||
export interface PrognosisDayInput {
|
||||
/** Local day key, `YYYY-MM-DD`. */
|
||||
day: string;
|
||||
/** The target: the day's own pleasantness, 0-10. `null` days are not fitted. */
|
||||
a1: number | null;
|
||||
/** The other four self-ratings, literal as the person set them. */
|
||||
dimensions: Partial<Record<MoodDimensionKey, number | null>>;
|
||||
/** The day's stored context, or `null` when no section was opened. */
|
||||
context: PrognosisContextInput | null;
|
||||
/** What the owning modules hold for the day. `null` is absence. */
|
||||
linked: {
|
||||
sleepAsleep: number | null;
|
||||
steps: number | null;
|
||||
activeEnergy: number | null;
|
||||
restingHeartRate: number | null;
|
||||
heartRateVariability: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** The numeric context fields, in capture order. */
|
||||
const CONTEXT_NUMERIC_FIELDS = [
|
||||
"workMinutes",
|
||||
"overtimeMinutes",
|
||||
"workLoad",
|
||||
"workSatisfaction",
|
||||
"contactQuality",
|
||||
"contactSupport",
|
||||
"leisureMinutes",
|
||||
"leisureJoy",
|
||||
"leisureRecovery",
|
||||
"eventValence",
|
||||
] as const satisfies readonly (keyof PrognosisContextInput)[];
|
||||
|
||||
/** The single-valued vocabularies, each expanded to one column per value. */
|
||||
const CONTEXT_SINGLE_VOCABULARIES = [
|
||||
{ field: "workStatus", keys: WORK_STATUS_KEYS },
|
||||
{ field: "contactForm", keys: CONTACT_FORM_KEYS },
|
||||
{ field: "contactExtent", keys: CONTACT_EXTENT_KEYS },
|
||||
{ field: "eventType", keys: EVENT_TYPE_KEYS },
|
||||
] as const;
|
||||
|
||||
/** The multi-select vocabularies, same expansion. */
|
||||
const CONTEXT_MULTI_VOCABULARIES = [
|
||||
{ field: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
|
||||
{ field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
|
||||
] as const;
|
||||
|
||||
function isNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every feature this day carries a value for.
|
||||
*
|
||||
* A feature the day says nothing about is ABSENT from the map — it is not
|
||||
* zero. The difference is the whole of rule 1: a day with no context row has
|
||||
* no work-status column at all, while a day whose work status is `off` has a
|
||||
* zero in every other work-status column and a one in that one.
|
||||
*/
|
||||
export function extractDayFeatures(
|
||||
day: PrognosisDayInput,
|
||||
): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
|
||||
for (const dimension of MOOD_DIMENSIONS) {
|
||||
// A1 is the target, not an input. Fitting a value on itself would produce
|
||||
// a perfect model of nothing.
|
||||
if (dimension.key === "a1") continue;
|
||||
const raw = day.dimensions[dimension.key];
|
||||
if (!isNumber(raw)) continue;
|
||||
// The orientation flip goes through the table that declares it, so a
|
||||
// dimension that becomes inverse later moves the chart and the model
|
||||
// together.
|
||||
out.set(
|
||||
dimensionFeatureKey(dimension.key),
|
||||
dimension.inverse ? dimension.max - raw : raw,
|
||||
);
|
||||
}
|
||||
|
||||
const context = day.context;
|
||||
if (context) {
|
||||
for (const field of CONTEXT_NUMERIC_FIELDS) {
|
||||
const raw = context[field];
|
||||
if (isNumber(raw)) out.set(contextNumericFeatureKey(field), raw);
|
||||
}
|
||||
for (const { field, keys } of CONTEXT_SINGLE_VOCABULARIES) {
|
||||
const value = context[field];
|
||||
// A field nobody answered contributes no columns. Zeroing them would
|
||||
// tell the fit "not overtime" on a day nobody said anything about work.
|
||||
if (typeof value !== "string" || value.length === 0) continue;
|
||||
for (const key of keys) {
|
||||
out.set(contextValueFeatureKey(field, key), value === key ? 1 : 0);
|
||||
}
|
||||
}
|
||||
for (const { field, keys } of CONTEXT_MULTI_VOCABULARIES) {
|
||||
const values = context[field];
|
||||
if (!Array.isArray(values) || values.length === 0) continue;
|
||||
const chosen = new Set(values);
|
||||
for (const key of keys) {
|
||||
out.set(contextValueFeatureKey(field, key), chosen.has(key) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const metric of LINKED_FEATURE_METRICS) {
|
||||
const raw = day.linked[metric];
|
||||
if (isNumber(raw)) out.set(linkedFeatureKey(metric), raw);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What the screening decided about one candidate, kept for the tests. */
|
||||
export interface ScreenedFeature {
|
||||
key: string;
|
||||
/** Days carrying both the feature and the target. */
|
||||
pairs: number;
|
||||
r: number;
|
||||
/** The shrunk estimate the effect floor is applied to. */
|
||||
shrunk: number;
|
||||
pValue: number;
|
||||
qValue: number;
|
||||
selected: boolean;
|
||||
/** Why it was refused, when it was. */
|
||||
reason?:
|
||||
| "coverage"
|
||||
| "too-few-pairs"
|
||||
| "no-variance"
|
||||
| "p-value"
|
||||
| "fdr"
|
||||
| "effect-size"
|
||||
| "occurrences"
|
||||
| "cap";
|
||||
}
|
||||
|
||||
/**
|
||||
* Which candidates earn a column, and why the others did not.
|
||||
*
|
||||
* Order of refusal matters and is the order below: coverage, then pairs, then
|
||||
* the raw p, then the family-wide FDR, then the effect floor, then the cap.
|
||||
* A feature that fails an earlier gate is not tested against a later one, so
|
||||
* the family the FDR corrects over is the set that was actually tested.
|
||||
*/
|
||||
export function screenFeatures(
|
||||
days: readonly PrognosisDayInput[],
|
||||
): ScreenedFeature[] {
|
||||
const targetDays = days.filter((d) => isNumber(d.a1));
|
||||
const perDay = new Map<string, Map<string, number>>();
|
||||
for (const day of targetDays) perDay.set(day.day, extractDayFeatures(day));
|
||||
|
||||
const candidates = new Set<string>();
|
||||
for (const features of perDay.values()) {
|
||||
for (const key of features.keys()) candidates.add(key);
|
||||
}
|
||||
|
||||
const coverageFloor = targetDays.length * MIN_FEATURE_COVERAGE;
|
||||
const refused: ScreenedFeature[] = [];
|
||||
const tested: Array<{
|
||||
key: string;
|
||||
pairs: number;
|
||||
r: number;
|
||||
shrunk: number;
|
||||
pValue: number;
|
||||
}> = [];
|
||||
|
||||
for (const key of [...candidates].sort()) {
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
let occurrences = 0;
|
||||
for (const day of targetDays) {
|
||||
const value = perDay.get(day.day)?.get(key);
|
||||
if (value === undefined) continue;
|
||||
xs.push(value);
|
||||
ys.push(day.a1 as number);
|
||||
if (value !== 0) occurrences += 1;
|
||||
}
|
||||
|
||||
if (xs.length < coverageFloor) {
|
||||
refused.push(zero(key, xs.length, "coverage"));
|
||||
continue;
|
||||
}
|
||||
// A one-hot column seen four times is not evidence, however well it
|
||||
// correlates. This is the ladder's occurrence floor, not a second one.
|
||||
if (!occurrencesSuffice(occurrences)) {
|
||||
refused.push(zero(key, xs.length, "occurrences"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = pearson({ xs, ys, minPairs: MIN_PAIRED_N });
|
||||
if (result.status === "insufficient") {
|
||||
refused.push(
|
||||
zero(
|
||||
key,
|
||||
result.n,
|
||||
result.reason === "no_variance" ? "no-variance" : "too-few-pairs",
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (result.pValue >= MAX_P_VALUE) {
|
||||
refused.push({
|
||||
key,
|
||||
pairs: result.n,
|
||||
r: result.r,
|
||||
shrunk: shrinkEstimate(result.r, result.n),
|
||||
pValue: result.pValue,
|
||||
qValue: 1,
|
||||
selected: false,
|
||||
reason: "p-value",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
tested.push({
|
||||
key,
|
||||
pairs: result.n,
|
||||
r: result.r,
|
||||
shrunk: shrinkEstimate(result.r, result.n),
|
||||
pValue: result.pValue,
|
||||
});
|
||||
}
|
||||
|
||||
const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
|
||||
const survivors: ScreenedFeature[] = [];
|
||||
tested.forEach((t, index) => {
|
||||
const qValue = qValues[index] ?? 1;
|
||||
if (qValue > FDR_Q) {
|
||||
survivors.push({ ...t, qValue, selected: false, reason: "fdr" });
|
||||
return;
|
||||
}
|
||||
if (Math.abs(t.shrunk) < EFFECT_SIZE_FLOOR) {
|
||||
survivors.push({ ...t, qValue, selected: false, reason: "effect-size" });
|
||||
return;
|
||||
}
|
||||
survivors.push({ ...t, qValue, selected: true });
|
||||
});
|
||||
|
||||
// The cap keeps the strongest, and a stable tie-break by key keeps two runs
|
||||
// over the same data returning the same columns.
|
||||
const admitted = survivors
|
||||
.filter((s) => s.selected)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Math.abs(b.shrunk) - Math.abs(a.shrunk) || a.key.localeCompare(b.key),
|
||||
);
|
||||
for (const feature of admitted.slice(MAX_MODEL_FEATURES)) {
|
||||
feature.selected = false;
|
||||
feature.reason = "cap";
|
||||
}
|
||||
|
||||
return [...survivors, ...refused].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function zero(
|
||||
key: string,
|
||||
pairs: number,
|
||||
reason: ScreenedFeature["reason"],
|
||||
): ScreenedFeature {
|
||||
return {
|
||||
key,
|
||||
pairs,
|
||||
r: 0,
|
||||
shrunk: 0,
|
||||
pValue: 1,
|
||||
qValue: 1,
|
||||
selected: false,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-feature centre and spread, over this account's own fit window. */
|
||||
export interface FeatureStandardisation {
|
||||
key: string;
|
||||
mean: number;
|
||||
sd: number;
|
||||
}
|
||||
|
||||
export interface FeatureMatrix {
|
||||
/** The selected feature keys, in column order. */
|
||||
features: string[];
|
||||
/** Standardised rows, one per complete day, oldest first. */
|
||||
rows: number[][];
|
||||
/** The target for each row, unstandardised (0-10). */
|
||||
targets: number[];
|
||||
/** The day each row is, so the validation can stay time-ordered. */
|
||||
days: string[];
|
||||
/** What each column was standardised by, needed to score a new day. */
|
||||
standardisation: FeatureStandardisation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the matrix: screen, then keep the complete days, then standardise.
|
||||
*
|
||||
* Returns `null` when nothing survives the screening or when no day carries
|
||||
* every surviving feature. That is a refusal, not an empty matrix — the caller
|
||||
* writes no forecast rather than one built on nothing.
|
||||
*/
|
||||
export function buildFeatureMatrix(
|
||||
days: readonly PrognosisDayInput[],
|
||||
): FeatureMatrix | null {
|
||||
const screened = screenFeatures(days);
|
||||
const selected = screened.filter((s) => s.selected).map((s) => s.key);
|
||||
if (selected.length === 0) return null;
|
||||
|
||||
const ordered = [...days]
|
||||
.filter((d) => isNumber(d.a1))
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
|
||||
const rawRows: number[][] = [];
|
||||
const targets: number[] = [];
|
||||
const dayKeys: string[] = [];
|
||||
for (const day of ordered) {
|
||||
const features = extractDayFeatures(day);
|
||||
const row: number[] = [];
|
||||
let complete = true;
|
||||
for (const key of selected) {
|
||||
const value = features.get(key);
|
||||
if (value === undefined) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
row.push(value);
|
||||
}
|
||||
// Complete cases only. A day missing one selected feature is a day the fit
|
||||
// does not see; nothing is filled in for it.
|
||||
if (!complete) continue;
|
||||
rawRows.push(row);
|
||||
targets.push(day.a1 as number);
|
||||
dayKeys.push(day.day);
|
||||
}
|
||||
|
||||
if (rawRows.length === 0) return null;
|
||||
|
||||
const standardisation = selected.map((key, column) => {
|
||||
const values = rawRows.map((row) => row[column]);
|
||||
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((sum, v) => sum + (v - mean) * (v - mean), 0) /
|
||||
values.length;
|
||||
return { key, mean, sd: Math.sqrt(variance) };
|
||||
});
|
||||
|
||||
// A column with no spread inside the complete-case set carries no
|
||||
// information and would divide by zero. It goes, and if that empties the
|
||||
// set the whole fit is refused.
|
||||
const keep = standardisation
|
||||
.map((s, index) => ({ ...s, index }))
|
||||
.filter((s) => s.sd > 0);
|
||||
if (keep.length === 0) return null;
|
||||
|
||||
return {
|
||||
features: keep.map((k) => k.key),
|
||||
rows: rawRows.map((row) => keep.map((k) => (row[k.index] - k.mean) / k.sd)),
|
||||
targets,
|
||||
days: dayKeys,
|
||||
standardisation: keep.map(({ key, mean, sd }) => ({ key, mean, sd })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardise one day's raw features against a stored fit, for scoring.
|
||||
*
|
||||
* `null` when the day does not carry every feature the fit uses — the same
|
||||
* complete-case rule the matrix applies, asked of a single day. A day that
|
||||
* cannot be scored gets no forecast, which is the honest answer.
|
||||
*/
|
||||
export function standardiseDay(
|
||||
day: PrognosisDayInput,
|
||||
standardisation: readonly FeatureStandardisation[],
|
||||
): number[] | null {
|
||||
const features = extractDayFeatures(day);
|
||||
const row: number[] = [];
|
||||
for (const { key, mean, sd } of standardisation) {
|
||||
const value = features.get(key);
|
||||
if (value === undefined || sd <= 0) return null;
|
||||
row.push((value - mean) / sd);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* One account's answer, computed and handed back as rows to write.
|
||||
*
|
||||
* Pure: it takes the days somebody logged and returns either the rows a pass
|
||||
* should store or a refusal with the reason. It does not touch the database,
|
||||
* and that is a boundary rather than a style — `MoodPrediction` has exactly
|
||||
* one writer, the refresh job, and a guard holds it there
|
||||
* (`src/__tests__/mood-prediction-write-surface-guard.test.ts`). Keeping the
|
||||
* arithmetic here and the write there is what lets the whole decision path be
|
||||
* tested without a container.
|
||||
*
|
||||
* **It refuses more often than it answers, and that is the design.** Too few
|
||||
* days, no feature that clears the screening, no recent day carrying every
|
||||
* feature the fit needs, a singular system — every one of them returns a
|
||||
* refusal and the account is left with no forecast at all. The surface then
|
||||
* says there is not enough to go on, which is true, instead of showing a
|
||||
* number built out of three days and a coincidence.
|
||||
*/
|
||||
import {
|
||||
buildFeatureMatrix,
|
||||
standardiseDay,
|
||||
type PrognosisDayInput,
|
||||
} from "./features";
|
||||
import { PROGNOSIS_WRITE_WINDOW_DAYS } from "./load";
|
||||
import { featureContributions, predictWithFit } from "./ridge";
|
||||
import { MIN_ENTRIES_FOR_FORECAST, forecastGate } from "./thresholds";
|
||||
import { fitPrognosisModel } from "./validate";
|
||||
|
||||
/** The lowest and highest value the target scale can express. */
|
||||
export const TARGET_MIN = 0;
|
||||
export const TARGET_MAX = 10;
|
||||
|
||||
/** One stored contribution: what a feature moved the expected value by. */
|
||||
export interface FeatureContribution {
|
||||
feature: string;
|
||||
contribution: number;
|
||||
}
|
||||
|
||||
/** One day's forecast, in the shape the row is written in. */
|
||||
export interface ForecastRow {
|
||||
date: string;
|
||||
predicted: number;
|
||||
ciLow: number;
|
||||
ciHigh: number;
|
||||
n: number;
|
||||
modelVersion: string;
|
||||
/** Ordered by absolute contribution, largest first. */
|
||||
contributions: FeatureContribution[];
|
||||
}
|
||||
|
||||
/** Why an account gets no forecast. Every one of these is a normal outcome. */
|
||||
export type ForecastRefusal =
|
||||
/** Below the ladder's forecast threshold. */
|
||||
| "too-few-days"
|
||||
/** Nothing recorded often enough, or varied enough, to screen in. */
|
||||
| "no-pattern"
|
||||
/** A pattern, but no recent day carrying every feature it needs. */
|
||||
| "no-scorable-day";
|
||||
|
||||
export type ForecastResult =
|
||||
| {
|
||||
status: "written";
|
||||
rows: ForecastRow[];
|
||||
/** Complete days the fit used. The `n` every surface must show. */
|
||||
n: number;
|
||||
modelVersion: string;
|
||||
/** How wide the band came out, for the job's event only. */
|
||||
bandWidth: number;
|
||||
featureCount: number;
|
||||
}
|
||||
| { status: "refused"; reason: ForecastRefusal };
|
||||
|
||||
function clampToScale(value: number): number {
|
||||
return Math.min(TARGET_MAX, Math.max(TARGET_MIN, value));
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit the account's window and score its most recent days.
|
||||
*
|
||||
* The ladder is read against the number of DAYS carrying a self-rating, which
|
||||
* is the number a person can see themselves growing. The `n` stored on each
|
||||
* row is the number of complete days the fit actually used, which can be
|
||||
* smaller — a day that is missing one of the fit's inputs cannot be part of
|
||||
* it. Both numbers are honest and they answer different questions; the one on
|
||||
* the surface beside the value is `n`, because that is what the value rests on.
|
||||
*/
|
||||
export function computeForecast(
|
||||
days: readonly PrognosisDayInput[],
|
||||
writeWindowDays: number = PROGNOSIS_WRITE_WINDOW_DAYS,
|
||||
): ForecastResult {
|
||||
const rated = days.filter((d) => typeof d.a1 === "number");
|
||||
const gate = forecastGate(rated.length);
|
||||
if (!gate.present) return { status: "refused", reason: "too-few-days" };
|
||||
|
||||
const matrix = buildFeatureMatrix(days);
|
||||
const model = matrix === null ? null : fitPrognosisModel(matrix);
|
||||
if (model === null) return { status: "refused", reason: "no-pattern" };
|
||||
|
||||
// The second floor, and the one the rated-day gate above cannot see. A day
|
||||
// missing one of the selected features is dropped rather than imputed, so a
|
||||
// sparse-context account can clear thirty rated days and still fit on a
|
||||
// handful of complete rows. `n` shown beside the value and a wide band on a
|
||||
// small fit keep it from being dishonest, but a forecast "built from twelve
|
||||
// days" past a floor that was supposed to mean thirty is a floor that does
|
||||
// not hold. The number of days the fit ACTUALLY used has to clear the same
|
||||
// threshold, not only the number of days that carried a rating.
|
||||
if (model.n < MIN_ENTRIES_FOR_FORECAST) {
|
||||
return { status: "refused", reason: "no-pattern" };
|
||||
}
|
||||
|
||||
const rows: ForecastRow[] = [];
|
||||
for (const day of rated.slice(-writeWindowDays)) {
|
||||
const standardised = standardiseDay(day, model.standardisation);
|
||||
// A day missing one of the fit's features cannot be scored, and nothing is
|
||||
// filled in for it. It simply has no forecast.
|
||||
if (standardised === null) continue;
|
||||
const raw = predictWithFit(model.fit, standardised);
|
||||
if (raw === null) continue;
|
||||
|
||||
rows.push({
|
||||
date: day.day,
|
||||
predicted: round(clampToScale(raw), 2),
|
||||
// The band travels with the prediction and is clamped to the same scale:
|
||||
// a lower edge below zero would be a claim the scale cannot express.
|
||||
ciLow: round(clampToScale(raw + model.bandLow), 2),
|
||||
ciHigh: round(clampToScale(raw + model.bandHigh), 2),
|
||||
n: model.n,
|
||||
modelVersion: model.modelVersion,
|
||||
contributions: featureContributions(
|
||||
model.fit,
|
||||
model.features,
|
||||
standardised,
|
||||
)
|
||||
.filter((c) => Number.isFinite(c.contribution))
|
||||
.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution))
|
||||
.map((c) => ({
|
||||
feature: c.feature,
|
||||
contribution: round(c.contribution, 3),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { status: "refused", reason: "no-scorable-day" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "written",
|
||||
rows,
|
||||
n: model.n,
|
||||
modelVersion: model.modelVersion,
|
||||
bandWidth: round(model.bandHigh - model.bandLow, 2),
|
||||
featureCount: model.features.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The database read behind the fit, and nothing else.
|
||||
*
|
||||
* Separate from `features.ts` on purpose. `features.ts` is pure, and it shares
|
||||
* `feature-keys.ts` with the client component that labels a stored feature key
|
||||
* (`components/insights/mood/prognosis-feature-label.ts` imports the parser
|
||||
* from there) — so those two modules form a Prisma-free island the browser
|
||||
* bundle can reach. A database import in `features.ts` would drag the client
|
||||
* towards that bundle; keeping the read here is what holds the line. The split
|
||||
* is the boundary, not a preference.
|
||||
*
|
||||
* **One row per day, taken whole from one entry.** A day with two entries
|
||||
* contributes the later one — its target, its dimensions and its context
|
||||
* together. Averaging the ratings and taking the context from whichever entry
|
||||
* happened to carry one would build a row nobody logged, and the model would
|
||||
* then be fitted on days that did not happen.
|
||||
*
|
||||
* The day key is the entry's own `date`, already resolved in the zone the
|
||||
* entry was written in. Re-deriving it here from `moodLoggedAt` would be a
|
||||
* second opinion about which day a row belongs to, which is the class of bug
|
||||
* the per-row `tz` column was added to end.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decodeKeyList } from "@/lib/mood/context-vocabulary";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { resolveLinkedDayFigures } from "@/lib/mood/linked-context";
|
||||
|
||||
import type { PrognosisDayInput } from "./features";
|
||||
|
||||
/**
|
||||
* How far back the fit looks.
|
||||
*
|
||||
* A year: long enough to hold a seasonal swing, short enough that a life that
|
||||
* changed two years ago is not still being fitted against today. Longer would
|
||||
* also make the per-user standardisation describe somebody the person no
|
||||
* longer is.
|
||||
*/
|
||||
export const PROGNOSIS_FIT_WINDOW_DAYS = 365;
|
||||
|
||||
/**
|
||||
* How many recent days the job writes a forecast for.
|
||||
*
|
||||
* Not the whole window: a forecast is worth storing for the days somebody
|
||||
* might still be looking at. Older rows are recomputable and are deleted on
|
||||
* every pass, which is also what keeps a stale forecast from outliving the
|
||||
* data it was fitted on.
|
||||
*/
|
||||
export const PROGNOSIS_WRITE_WINDOW_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Hard cap on the rows one fit reads.
|
||||
*
|
||||
* A year holds 365 days and the fold keeps one entry per day, so the cap only
|
||||
* ever bites on an account that logs many times a day — and there it bites on
|
||||
* the oldest days, which is the right end to lose. It is here because an
|
||||
* unbounded `findMany` in a nightly job over an account with years of history
|
||||
* is a table scan waiting for the account that has one.
|
||||
*/
|
||||
export const MAX_PROGNOSIS_ROWS = 5000;
|
||||
|
||||
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
|
||||
export function dayKeyBefore(now: Date, days: number): string {
|
||||
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day of the fit window, in day order, with everything the matrix needs.
|
||||
*
|
||||
* The linked figures come from the resolver that owns them, over the whole
|
||||
* window in one query. They are read in the account's display zone: the entry
|
||||
* rows carry their own zone for their own day key, but a step count has to be
|
||||
* bucketed under some zone, and the account's own is the one every other
|
||||
* surface uses.
|
||||
*/
|
||||
export async function loadPrognosisDays(
|
||||
userId: string,
|
||||
now: Date,
|
||||
timezone: string | null,
|
||||
windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS,
|
||||
): Promise<PrognosisDayInput[]> {
|
||||
const since = dayKeyBefore(now, windowDays);
|
||||
const entries = await prisma.moodEntry.findMany({
|
||||
where: { userId, deletedAt: null, date: { gte: since } },
|
||||
// Descending, and the fold below keeps the FIRST row it sees per day, so
|
||||
// "the last entry of a day wins" is the read order rather than a
|
||||
// comparison. Descending also puts the cap on the right end: an account
|
||||
// that logs many times a day loses its oldest days to the limit, never
|
||||
// its newest ones, and the newest are the days a forecast is written for.
|
||||
orderBy: [{ date: "desc" }, { moodLoggedAt: "desc" }],
|
||||
take: MAX_PROGNOSIS_ROWS,
|
||||
select: {
|
||||
date: true,
|
||||
moodA1: true,
|
||||
stressA2: true,
|
||||
energyA3: true,
|
||||
connectionA4: true,
|
||||
stabilityA5: true,
|
||||
context: {
|
||||
select: {
|
||||
workStatus: true,
|
||||
workMinutes: true,
|
||||
overtimeMinutes: true,
|
||||
workLoad: true,
|
||||
workSatisfaction: true,
|
||||
contactCircles: true,
|
||||
contactForm: true,
|
||||
contactExtent: true,
|
||||
contactQuality: true,
|
||||
contactSupport: true,
|
||||
leisureCategories: true,
|
||||
leisureMinutes: true,
|
||||
leisureJoy: true,
|
||||
leisureRecovery: true,
|
||||
eventType: true,
|
||||
eventValence: true,
|
||||
// The note is deliberately not selected. It is encrypted free text
|
||||
// about somebody's day and the model has no use for it; a fit does
|
||||
// not read prose, and a query that decrypted it anyway would be
|
||||
// reading a diary to compute an average.
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const byDay = new Map<string, PrognosisDayInput>();
|
||||
for (const entry of entries) {
|
||||
// The rows arrive newest first, so the first one seen for a day is that
|
||||
// day's last entry.
|
||||
if (byDay.has(entry.date)) continue;
|
||||
byDay.set(entry.date, {
|
||||
day: entry.date,
|
||||
a1: entry.moodA1,
|
||||
dimensions: {
|
||||
a2: entry.stressA2,
|
||||
a3: entry.energyA3,
|
||||
a4: entry.connectionA4,
|
||||
a5: entry.stabilityA5,
|
||||
},
|
||||
context: entry.context
|
||||
? {
|
||||
workStatus: entry.context.workStatus,
|
||||
workMinutes: entry.context.workMinutes,
|
||||
overtimeMinutes: entry.context.overtimeMinutes,
|
||||
workLoad: entry.context.workLoad,
|
||||
workSatisfaction: entry.context.workSatisfaction,
|
||||
contactCircles: decodeKeyList(entry.context.contactCircles),
|
||||
contactForm: entry.context.contactForm,
|
||||
contactExtent: entry.context.contactExtent,
|
||||
contactQuality: entry.context.contactQuality,
|
||||
contactSupport: entry.context.contactSupport,
|
||||
leisureCategories: decodeKeyList(entry.context.leisureCategories),
|
||||
leisureMinutes: entry.context.leisureMinutes,
|
||||
leisureJoy: entry.context.leisureJoy,
|
||||
leisureRecovery: entry.context.leisureRecovery,
|
||||
eventType: entry.context.eventType,
|
||||
eventValence: entry.context.eventValence,
|
||||
}
|
||||
: null,
|
||||
linked: {
|
||||
sleepAsleep: null,
|
||||
steps: null,
|
||||
activeEnergy: null,
|
||||
restingHeartRate: null,
|
||||
heartRateVariability: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const days = [...byDay.keys()].sort();
|
||||
if (days.length === 0) return [];
|
||||
|
||||
const linked = await resolveLinkedDayFigures(
|
||||
userId,
|
||||
days,
|
||||
timezone ?? DEFAULT_TIMEZONE,
|
||||
);
|
||||
for (const day of days) {
|
||||
const figures = linked.get(day);
|
||||
if (figures) byDay.get(day)!.linked = figures;
|
||||
}
|
||||
|
||||
return days.map((day) => byDay.get(day)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many days of the window carry a self-rated pleasantness.
|
||||
*
|
||||
* The number the ladder is read against, and the number a surface quotes when
|
||||
* it explains why there is nothing yet. Days, not entries: two entries on one
|
||||
* Tuesday are one Tuesday, and counting them as two would let somebody reach
|
||||
* a threshold by logging twice a day for a fortnight.
|
||||
*/
|
||||
export async function countQualifyingDays(
|
||||
userId: string,
|
||||
now: Date,
|
||||
windowDays: number = PROGNOSIS_FIT_WINDOW_DAYS,
|
||||
): Promise<number> {
|
||||
const since = dayKeyBefore(now, windowDays);
|
||||
const days = await prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
moodA1: { not: null },
|
||||
date: { gte: since },
|
||||
},
|
||||
distinct: ["date"],
|
||||
select: { date: true },
|
||||
// Bounded by the window either way — a year holds 366 distinct day keys —
|
||||
// and the cap says so rather than trusting the window to be small.
|
||||
take: 400,
|
||||
});
|
||||
return days.length;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* What the surfaces read: the two values of a day, side by side, never merged.
|
||||
*
|
||||
* The self-assessment is the leading value and is returned exactly as it was
|
||||
* stored. The prognosis is the second reading — computed, banded, counted, and
|
||||
* not editable. The deviation between them is the finding, and it is measured
|
||||
* against the BAND rather than against the midpoint: a rating inside the band
|
||||
* is a day that went as the person's own past days suggest, and only a rating
|
||||
* outside it is worth a sentence.
|
||||
*
|
||||
* One reader for every surface. The route, the Coach snapshot and the daily
|
||||
* briefing all come through here, so a change to what "the day's forecast"
|
||||
* means moves all three together instead of two of them.
|
||||
*
|
||||
* Absence is explicit and carries its reason and its count. `no-output-yet`
|
||||
* and `learning-phase` come from the ladder; `no-pattern` is the honest answer
|
||||
* when there are plenty of days and nothing in them the fit could hold on to.
|
||||
* None of the three is an error and none of them is a zero.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
dayKeyAgeInDays,
|
||||
isCurrentForTodayClaim,
|
||||
} from "@/lib/insights/measurement-freshness";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
|
||||
import type { FeatureContribution } from "./forecast";
|
||||
import { countQualifyingDays } from "./load";
|
||||
import {
|
||||
forecastGate,
|
||||
seasonalComparisonsUnlocked,
|
||||
type PrognosisStage,
|
||||
} from "./thresholds";
|
||||
|
||||
/** How the day's own rating sat against the band. */
|
||||
export type PrognosisDeviation = "within" | "above" | "below";
|
||||
|
||||
export interface PrognosisPresent {
|
||||
present: true;
|
||||
/** The day the forecast is about, `YYYY-MM-DD`. */
|
||||
date: string;
|
||||
/** The expected pleasantness, 0-10. Never shown without `n` and the band. */
|
||||
predicted: number;
|
||||
ciLow: number | null;
|
||||
ciHigh: number | null;
|
||||
/** Complete days the fit used. Displayed wherever `predicted` is. */
|
||||
n: number;
|
||||
modelVersion: string;
|
||||
computedAt: string;
|
||||
/**
|
||||
* Whole days between the forecast's day and the reader's today. A forecast
|
||||
* about the day before yesterday cannot back a sentence about today, and
|
||||
* `current` is the same rule every other present-tense claim in the product
|
||||
* uses — one constant, not a second one.
|
||||
*/
|
||||
ageDays: number | null;
|
||||
current: boolean;
|
||||
/** Below the regular threshold the forecast is labelled provisional. */
|
||||
provisional: boolean;
|
||||
/**
|
||||
* The rung the value is shown at. Only the three rungs that CARRY a value —
|
||||
* a below-floor reading never reaches here, it becomes the absence shape —
|
||||
* so the type matches the published enum exactly and an `insufficient` /
|
||||
* `learning` stage cannot be constructed for a present reading.
|
||||
*/
|
||||
stage: Extract<PrognosisStage, "provisional" | "regular" | "seasonal">;
|
||||
/** Days of history the ladder was read against. */
|
||||
entries: number;
|
||||
/** Whether weekday and seasonal comparisons have unlocked. */
|
||||
seasonalUnlocked: boolean;
|
||||
/** The person's own rating for that day. `null` when they gave none. */
|
||||
selfAssessment: number | null;
|
||||
/** Where that rating sat against the band. `null` without a rating. */
|
||||
deviation: PrognosisDeviation | null;
|
||||
/** How far outside the band it sat, in scale points. `0` when inside. */
|
||||
deviationAmount: number | null;
|
||||
/** What the model weighted for that day, largest absolute first. */
|
||||
contributions: FeatureContribution[];
|
||||
}
|
||||
|
||||
export interface PrognosisAbsent {
|
||||
present: false;
|
||||
/**
|
||||
* `no-output-yet` — too few days for anything at all.
|
||||
* `learning-phase` — enough to describe, not enough to forecast.
|
||||
* `no-pattern` — enough days, nothing in them the fit could hold on to.
|
||||
*/
|
||||
reason: "no-output-yet" | "learning-phase" | "no-pattern";
|
||||
/** Days of history carrying a self-rating. */
|
||||
entries: number;
|
||||
/** Days at which the next step unlocks, when a count is what is missing. */
|
||||
nextThreshold: number | null;
|
||||
}
|
||||
|
||||
export type PrognosisReading = PrognosisPresent | PrognosisAbsent;
|
||||
|
||||
/** Parse the stored contributions. A malformed value reads as none. */
|
||||
export function parseContributions(stored: string): FeatureContribution[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(stored);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(item): item is FeatureContribution =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
typeof (item as FeatureContribution).feature === "string" &&
|
||||
typeof (item as FeatureContribution).contribution === "number",
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a rating sits against a band. */
|
||||
export function deviationAgainstBand(
|
||||
actual: number,
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
): { deviation: PrognosisDeviation; amount: number } | null {
|
||||
if (low === null || high === null) return null;
|
||||
if (actual > high) return { deviation: "above", amount: actual - high };
|
||||
if (actual < low) return { deviation: "below", amount: low - actual };
|
||||
return { deviation: "within", amount: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest forecast an account holds, with the day's own rating beside it.
|
||||
*
|
||||
* The job writes a trailing window and the newest row in it is normally
|
||||
* yesterday's — the pass runs before anybody has logged today. That is why
|
||||
* the age travels with the value instead of being assumed to be zero.
|
||||
*/
|
||||
export async function readMoodPrognosis(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
timezone?: string | null,
|
||||
): Promise<PrognosisReading> {
|
||||
const row = await prisma.moodPrediction.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { date: "desc" },
|
||||
select: {
|
||||
date: true,
|
||||
predicted: true,
|
||||
ciLow: true,
|
||||
ciHigh: true,
|
||||
n: true,
|
||||
modelVersion: true,
|
||||
features: true,
|
||||
computedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (row === null) {
|
||||
// No row is not automatically "too few days": an account can have a year
|
||||
// of entries and still get no forecast, because nothing in them cleared
|
||||
// the screening. The count decides which of the three answers is true.
|
||||
const entries = await countQualifyingDays(userId, now);
|
||||
const gate = forecastGate(entries);
|
||||
if (!gate.present) {
|
||||
return {
|
||||
present: false,
|
||||
reason: gate.reason,
|
||||
entries,
|
||||
nextThreshold: gate.nextThreshold,
|
||||
};
|
||||
}
|
||||
return {
|
||||
present: false,
|
||||
reason: "no-pattern",
|
||||
entries,
|
||||
nextThreshold: null,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await countQualifyingDays(userId, now);
|
||||
|
||||
// A row on disk is not enough to answer with. The job writes nightly, so
|
||||
// between an entry deletion and the next pass a row outlives its own
|
||||
// eligibility. The read path re-reads the ladder and refuses below the
|
||||
// floor, exactly as the job would — otherwise this route serves a value the
|
||||
// account no longer has the data to support for up to a day, and it serves
|
||||
// it under a stage ("insufficient" / "learning") that is not in the
|
||||
// published enum, which fails a strict client decode of the whole payload.
|
||||
const gate = forecastGate(entries);
|
||||
if (!gate.present) {
|
||||
return {
|
||||
present: false,
|
||||
reason: gate.reason,
|
||||
entries,
|
||||
nextThreshold: gate.nextThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
const today = moodDateKey(now, timezone ?? DEFAULT_TIMEZONE);
|
||||
const ageDays = dayKeyAgeInDays(row.date, today);
|
||||
const entry = await prisma.moodEntry.findFirst({
|
||||
where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { moodA1: true },
|
||||
});
|
||||
const selfAssessment = entry?.moodA1 ?? null;
|
||||
const against =
|
||||
selfAssessment === null
|
||||
? null
|
||||
: deviationAgainstBand(selfAssessment, row.ciLow, row.ciHigh);
|
||||
|
||||
return {
|
||||
present: true,
|
||||
date: row.date,
|
||||
predicted: row.predicted,
|
||||
ciLow: row.ciLow,
|
||||
ciHigh: row.ciHigh,
|
||||
n: row.n,
|
||||
modelVersion: row.modelVersion,
|
||||
computedAt: row.computedAt.toISOString(),
|
||||
ageDays,
|
||||
current: isCurrentForTodayClaim(ageDays),
|
||||
// `gate` is the present variant here — the absence arm returned above — so
|
||||
// the stage is one of provisional / regular / seasonal, never the
|
||||
// out-of-enum "insufficient" or "learning" a stale row could otherwise
|
||||
// carry onto the wire.
|
||||
provisional: gate.provisional,
|
||||
stage: gate.stage,
|
||||
entries,
|
||||
seasonalUnlocked: seasonalComparisonsUnlocked(entries),
|
||||
selfAssessment,
|
||||
deviation: against?.deviation ?? null,
|
||||
deviationAmount: against?.amount ?? null,
|
||||
contributions: parseContributions(row.features),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Ridge regression, closed form, in about sixty lines of arithmetic.
|
||||
*
|
||||
* `(XᵀX + λI)β = Xᵀy`, solved by Gaussian elimination with partial pivoting.
|
||||
* That is the whole model. It is here rather than in a dependency because a
|
||||
* dozen standardised columns is a 12×12 system, and adding a package to solve
|
||||
* one of those would be adding a supply chain to avoid writing a loop.
|
||||
*
|
||||
* Two properties the rest of the feature relies on:
|
||||
*
|
||||
* * **The intercept is not penalised, because there is no intercept in the
|
||||
* system.** The target is centred before the solve and the mean is added
|
||||
* back afterwards, which is the standard treatment: penalising the
|
||||
* intercept would shrink every prediction towards zero rather than
|
||||
* towards the person's own average day, and towards zero on a 0-10
|
||||
* pleasantness scale means "towards the worst day imaginable".
|
||||
* * **λ is never zero.** With more columns than the ridge penalty can
|
||||
* afford, an unpenalised solve is a memoriser: it fits the training days
|
||||
* perfectly and says nothing about any other day. The grid the validation
|
||||
* searches starts above zero for that reason.
|
||||
*
|
||||
* This is arithmetic, not an AI surface. No provider, no consent gate, no
|
||||
* egress — see the note at the top of the refresh job.
|
||||
*/
|
||||
|
||||
/** A fitted model: one coefficient per standardised column, plus the mean. */
|
||||
export interface RidgeFit {
|
||||
/** One per column of the matrix it was fitted on, in the same order. */
|
||||
coefficients: number[];
|
||||
/** The mean of the training targets, added back on every prediction. */
|
||||
intercept: number;
|
||||
/**
|
||||
* The mean of each training column, subtracted before every prediction.
|
||||
*
|
||||
* Usually zero to floating point, because the matrix arrives z-scored. It is
|
||||
* carried anyway rather than assumed: centring the target while leaving the
|
||||
* columns uncentred biases every coefficient, and the bias is small enough
|
||||
* to look like rounding. Somebody handing this function a raw matrix should
|
||||
* get the right answer, not a plausible one.
|
||||
*/
|
||||
columnMeans: number[];
|
||||
/** The penalty this fit used. */
|
||||
lambda: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solve `A x = b` by Gaussian elimination with partial pivoting.
|
||||
*
|
||||
* `null` when the system is singular to working precision. The caller treats
|
||||
* that as "no fit" rather than as a fit of zeros: a singular system means the
|
||||
* columns are telling the same story twice, and a zero vector would silently
|
||||
* become a model that predicts the average and calls it a pattern.
|
||||
*/
|
||||
export function solveLinearSystem(
|
||||
matrix: readonly number[][],
|
||||
vector: readonly number[],
|
||||
): number[] | null {
|
||||
const n = vector.length;
|
||||
if (matrix.length !== n) return null;
|
||||
// Copy: the caller's XᵀX is reused across the λ grid and elimination is
|
||||
// destructive.
|
||||
const a = matrix.map((row, i) => [...row, vector[i]]);
|
||||
|
||||
for (let column = 0; column < n; column++) {
|
||||
let pivot = column;
|
||||
for (let row = column + 1; row < n; row++) {
|
||||
if (Math.abs(a[row][column]) > Math.abs(a[pivot][column])) pivot = row;
|
||||
}
|
||||
if (Math.abs(a[pivot][column]) < 1e-10) return null;
|
||||
if (pivot !== column) {
|
||||
const swap = a[pivot];
|
||||
a[pivot] = a[column];
|
||||
a[column] = swap;
|
||||
}
|
||||
const pivotValue = a[column][column];
|
||||
for (let row = column + 1; row < n; row++) {
|
||||
const factor = a[row][column] / pivotValue;
|
||||
if (factor === 0) continue;
|
||||
for (let k = column; k <= n; k++) a[row][k] -= factor * a[column][k];
|
||||
}
|
||||
}
|
||||
|
||||
const solution = new Array<number>(n).fill(0);
|
||||
for (let row = n - 1; row >= 0; row--) {
|
||||
let sum = a[row][n];
|
||||
for (let k = row + 1; k < n; k++) sum -= a[row][k] * solution[k];
|
||||
solution[row] = sum / a[row][row];
|
||||
}
|
||||
return solution.every((v) => Number.isFinite(v)) ? solution : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit `rows` against `targets` at one penalty.
|
||||
*
|
||||
* `rows` are already standardised per user (see `features.ts`), so the columns
|
||||
* are comparable and one λ is a defensible penalty across all of them —
|
||||
* which is not true of raw minutes beside a 0-10 rating.
|
||||
*/
|
||||
export function fitRidge(
|
||||
rows: readonly (readonly number[])[],
|
||||
targets: readonly number[],
|
||||
lambda: number,
|
||||
): RidgeFit | null {
|
||||
const n = rows.length;
|
||||
if (n === 0 || targets.length !== n) return null;
|
||||
const columns = rows[0].length;
|
||||
if (columns === 0) return null;
|
||||
if (rows.some((row) => row.length !== columns)) return null;
|
||||
|
||||
const intercept = targets.reduce((sum, v) => sum + v, 0) / n;
|
||||
const centredTargets = targets.map((v) => v - intercept);
|
||||
const columnMeans = Array.from({ length: columns }, (_, j) => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) sum += rows[i][j];
|
||||
return sum / n;
|
||||
});
|
||||
const centredRows = rows.map((row) => row.map((v, j) => v - columnMeans[j]));
|
||||
|
||||
const xtx: number[][] = Array.from({ length: columns }, () =>
|
||||
new Array<number>(columns).fill(0),
|
||||
);
|
||||
const xty = new Array<number>(columns).fill(0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const row = centredRows[i];
|
||||
for (let j = 0; j < columns; j++) {
|
||||
xty[j] += row[j] * centredTargets[i];
|
||||
for (let k = j; k < columns; k++) {
|
||||
const product = row[j] * row[k];
|
||||
xtx[j][k] += product;
|
||||
if (k !== j) xtx[k][j] += product;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let j = 0; j < columns; j++) xtx[j][j] += lambda;
|
||||
|
||||
const coefficients = solveLinearSystem(xtx, xty);
|
||||
if (coefficients === null) return null;
|
||||
return { coefficients, intercept, columnMeans, lambda };
|
||||
}
|
||||
|
||||
/** Score one standardised row. */
|
||||
export function predictWithFit(
|
||||
fit: RidgeFit,
|
||||
row: readonly number[],
|
||||
): number | null {
|
||||
if (row.length !== fit.coefficients.length) return null;
|
||||
let sum = fit.intercept;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
sum += fit.coefficients[i] * (row[i] - fit.columnMeans[i]);
|
||||
}
|
||||
return Number.isFinite(sum) ? sum : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-feature contributions of one scored day.
|
||||
*
|
||||
* `coefficient × the day's standardised value`, which is what the stored
|
||||
* `features` column holds and what the explanation line is generated from.
|
||||
* Signed, so a feature that pulled the expected value down reads as having
|
||||
* pulled it down, and the parts sum to the distance between the prediction and
|
||||
* the person's own average day.
|
||||
*/
|
||||
export function featureContributions(
|
||||
fit: RidgeFit,
|
||||
featureKeys: readonly string[],
|
||||
row: readonly number[],
|
||||
): Array<{ feature: string; contribution: number }> {
|
||||
return featureKeys.map((feature, index) => ({
|
||||
feature,
|
||||
contribution:
|
||||
fit.coefficients[index] * (row[index] - fit.columnMeans[index]),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* How much history a prognosis needs before it is allowed to say anything.
|
||||
*
|
||||
* This is the most dangerous file in the feature, and it is dangerous for one
|
||||
* reason: **the failure mode is silent.** A forecast built from eight days
|
||||
* renders exactly like a forecast built from eighty. Nobody looking at the
|
||||
* screen can tell the difference, and the person reading it is being told
|
||||
* something about their own health record. So every floor is a named constant
|
||||
* with a boundary test, and every one of those tests has been watched failing
|
||||
* — a gate nobody has seen go red is a decoration, not a gate.
|
||||
*
|
||||
* The ladder has five rungs, and each rung says what the surface may claim:
|
||||
*
|
||||
* | entries | stage | what may be shown |
|
||||
* |---------|---------------|------------------------------------------------|
|
||||
* | 0-14 | `insufficient`| nothing beyond the self-assessment trend |
|
||||
* | 15-29 | `learning` | descriptive hints, labelled as a learning phase |
|
||||
* | 30-59 | `provisional` | a forecast, labelled provisional |
|
||||
* | 60-89 | `regular` | a forecast without the provisional label |
|
||||
* | 90+ | `seasonal` | plus weekday and seasonal comparisons |
|
||||
*
|
||||
* Absence is `{ present: false }` with a reason and the count, never `0` and
|
||||
* never an empty forecast object with a null inside it. That shape is a
|
||||
* contract elsewhere in the product already — the MCP surface means exactly
|
||||
* "honestly not recorded" by it — and a surface that answered zero here would
|
||||
* be claiming a bad day where the truth is that nothing can be said yet.
|
||||
*
|
||||
* **Two numbers are deliberately NOT minted here.** The paired-observation
|
||||
* floor for a correlation is `MIN_PAIRED_N` in
|
||||
* `src/lib/insights/correlations.ts`, and the screening in `features.ts`
|
||||
* imports it from there; a second constant for the same idea is how two
|
||||
* surfaces start disagreeing about what counts as enough. The occurrence floor
|
||||
* below is the crosstab's own floor by assignment rather than by coincidence,
|
||||
* for the same reason — Phase 2's context comparison says at its declaration
|
||||
* that this file would import it, and this file does.
|
||||
*/
|
||||
import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
|
||||
|
||||
/**
|
||||
* Below this, no output at all — not even a descriptive hint.
|
||||
*
|
||||
* Fifteen days of a person's life is not a pattern, and a surface that
|
||||
* described one would be describing noise back to somebody who would
|
||||
* reasonably believe it.
|
||||
*/
|
||||
export const MIN_ENTRIES_LEARNING_PHASE = 15;
|
||||
|
||||
/**
|
||||
* Below this, descriptive hints only, labelled as a learning phase. At or
|
||||
* above it, a fitted forecast is allowed.
|
||||
*/
|
||||
export const MIN_ENTRIES_FOR_FORECAST = 30;
|
||||
|
||||
/** Below this, a forecast is shown but labelled provisional. */
|
||||
export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60;
|
||||
|
||||
/**
|
||||
* Below this, a weekday or seasonal comparison built on the forecast may not
|
||||
* be shown.
|
||||
*
|
||||
* Two things it does NOT do, both worth stating because the omission is a
|
||||
* decision rather than an oversight:
|
||||
*
|
||||
* * It does not retro-gate the weekday board the mood insights page has
|
||||
* carried for releases. That board is built from the entry aggregates,
|
||||
* not from this fit, and it has its own floors; hiding it from accounts
|
||||
* under ninety days would take a surface away from people who have been
|
||||
* reading it, to enforce a rule about a different calculation.
|
||||
* * It does not build a second weekday board. One exists, and a second
|
||||
* answer to "how do your Mondays go" is exactly the drift this milestone
|
||||
* spent its effort avoiding.
|
||||
*
|
||||
* What it does is gate the `seasonalUnlocked` flag the forecast publishes, so
|
||||
* any surface that wants to hang a weekday claim off THIS comparison — the
|
||||
* clients included — asks one place whether the history supports one.
|
||||
*/
|
||||
export const MIN_ENTRIES_FOR_SEASONAL = 90;
|
||||
|
||||
/**
|
||||
* How often a tag or a context value must have been recorded before it may
|
||||
* appear in an explanation at all.
|
||||
*
|
||||
* The crosstab's floor, taken rather than re-declared: it is the same
|
||||
* question ("has this been seen often enough to be worth a sentence") asked
|
||||
* by a different surface, and two answers to it would drift.
|
||||
*/
|
||||
export const MIN_TAG_OCCURRENCES = CONTEXT_MIN_PRESENT_DAYS;
|
||||
|
||||
// A per-weekday observation floor was drafted here for a weekday-comparison
|
||||
// board built on the forecast. That board was not built — the forecast
|
||||
// publishes `seasonalUnlocked` and leaves the weekday view to the mood
|
||||
// insights page, which has its own floors — so the constant had no consumer
|
||||
// and was removed rather than left as a number nothing reads. If a weekday
|
||||
// comparison on THIS fit is ever built, reintroduce the floor with it.
|
||||
|
||||
/** Where an account sits on the ladder. */
|
||||
export type PrognosisStage =
|
||||
"insufficient" | "learning" | "provisional" | "regular" | "seasonal";
|
||||
|
||||
/**
|
||||
* Why there is no forecast. The reason travels with the count and with the
|
||||
* next rung, so a surface can say "eleven of fifteen" instead of an error.
|
||||
*/
|
||||
export type PrognosisAbsenceReason = "no-output-yet" | "learning-phase";
|
||||
|
||||
export interface PrognosisAbsent {
|
||||
present: false;
|
||||
reason: PrognosisAbsenceReason;
|
||||
stage: Extract<PrognosisStage, "insufficient" | "learning">;
|
||||
/** Qualifying entries the account has. */
|
||||
entries: number;
|
||||
/** Entries at which the next thing becomes available. */
|
||||
nextThreshold: number;
|
||||
}
|
||||
|
||||
export interface PrognosisAvailable {
|
||||
present: true;
|
||||
stage: Extract<PrognosisStage, "provisional" | "regular" | "seasonal">;
|
||||
entries: number;
|
||||
/** The forecast carries a provisional label below the regular threshold. */
|
||||
provisional: boolean;
|
||||
}
|
||||
|
||||
export type PrognosisGate = PrognosisAbsent | PrognosisAvailable;
|
||||
|
||||
/**
|
||||
* Which rung this many qualifying entries reaches.
|
||||
*
|
||||
* Every comparison is `>=`, so the constant names the first value that
|
||||
* qualifies rather than the last that does not. The boundary tests pin both
|
||||
* sides of each one.
|
||||
*/
|
||||
export function prognosisStage(entries: number): PrognosisStage {
|
||||
if (entries >= MIN_ENTRIES_FOR_SEASONAL) return "seasonal";
|
||||
if (entries >= MIN_ENTRIES_FOR_REGULAR_FORECAST) return "regular";
|
||||
if (entries >= MIN_ENTRIES_FOR_FORECAST) return "provisional";
|
||||
if (entries >= MIN_ENTRIES_LEARNING_PHASE) return "learning";
|
||||
return "insufficient";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a forecast may be shown for an account with this many qualifying
|
||||
* entries, and if not, why not and what would change it.
|
||||
*/
|
||||
export function forecastGate(entries: number): PrognosisGate {
|
||||
const stage = prognosisStage(entries);
|
||||
if (stage === "insufficient") {
|
||||
return {
|
||||
present: false,
|
||||
reason: "no-output-yet",
|
||||
stage,
|
||||
entries,
|
||||
nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
|
||||
};
|
||||
}
|
||||
if (stage === "learning") {
|
||||
return {
|
||||
present: false,
|
||||
reason: "learning-phase",
|
||||
stage,
|
||||
entries,
|
||||
nextThreshold: MIN_ENTRIES_FOR_FORECAST,
|
||||
};
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
stage,
|
||||
entries,
|
||||
provisional: stage === "provisional",
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether weekday and seasonal comparisons have unlocked for this account. */
|
||||
export function seasonalComparisonsUnlocked(entries: number): boolean {
|
||||
return entries >= MIN_ENTRIES_FOR_SEASONAL;
|
||||
}
|
||||
|
||||
/** Whether a tag or context value has been recorded often enough to name. */
|
||||
export function occurrencesSuffice(occurrences: number): boolean {
|
||||
return occurrences >= MIN_TAG_OCCURRENCES;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Choosing the penalty, and measuring how wrong the model is, without letting
|
||||
* it see the future.
|
||||
*
|
||||
* **Rolling origin, expanding window. Never a random k-fold.** Days are not
|
||||
* exchangeable: a Tuesday sits between a Monday and a Wednesday that resemble
|
||||
* it, and a shuffled split puts Wednesday in the training set and asks the
|
||||
* model about Tuesday. The result is a model that looks excellent and is
|
||||
* useless, and nothing about the number it reports says so. So the folds are
|
||||
* ranges of a time-ordered array, the training range always ends where the
|
||||
* validation range starts, and `rolling-origin.test.ts` asserts that on the
|
||||
* fold indices and on the day keys themselves rather than on the resulting
|
||||
* error — an error-based assertion would pass a leaking split whenever the
|
||||
* leak happened to be small.
|
||||
*
|
||||
* **The band comes from the validation residuals**, not from a textbook
|
||||
* standard error on the training fit. A training-fit interval on a model with
|
||||
* twelve columns and thirty rows is narrow by construction and says nothing;
|
||||
* the spread of the errors the model actually made on days it had not seen is
|
||||
* the honest width. It widens when the model is bad, which is the property the
|
||||
* surface depends on to report low confidence instead of a confident wrong
|
||||
* number.
|
||||
*
|
||||
* **What is out-of-sample here, and what is not — stated exactly, because the
|
||||
* boundary is easy to overclaim.** The RIDGE FIT is honest: within every fold
|
||||
* the coefficients are solved on the training prefix alone and scored on
|
||||
* validation rows the fit never saw, and no training fold holds a row dated
|
||||
* after its validation fold. That is the claim `rolling-origin.test.ts`
|
||||
* proves, and it is the one that matters for "does λ generalise".
|
||||
*
|
||||
* Two steps sit OUTSIDE that guarantee, on purpose:
|
||||
*
|
||||
* * **Feature selection.** The screening in `features.ts` runs once over the
|
||||
* whole window before the split, so the feature set a fold trains on was
|
||||
* chosen with that fold's validation rows in view. This is the classic
|
||||
* "screen, then cross-validate" optimism.
|
||||
* * **Standardisation.** The per-user mean and spread are taken over the
|
||||
* whole window too, so a validation row is z-scored against statistics its
|
||||
* own value helped compute.
|
||||
*
|
||||
* Both make the reported error slightly better than a fully-nested procedure
|
||||
* would — measured here at roughly one part in sixty on the mean absolute
|
||||
* error and a couple of points of coverage, i.e. small enough that the band is
|
||||
* not dishonest but not zero. Nesting the screening and the standardisation
|
||||
* inside each fold would remove even that, at the cost of a per-fold feature
|
||||
* set and a materially larger amount of machinery; the trade was taken the
|
||||
* cheap way deliberately, and this comment is the price of taking it — a
|
||||
* reader must not read "no fold sees ahead" as covering the SELECTION, only
|
||||
* the FIT. If the feature set ever grows past a handful of strong columns, or
|
||||
* the optimism is ever measured larger, nest it.
|
||||
*/
|
||||
import { fitRidge, predictWithFit, type RidgeFit } from "./ridge";
|
||||
import type { FeatureMatrix } from "./features";
|
||||
|
||||
/**
|
||||
* The stored model identity. Bump it whenever the feature set, the screening
|
||||
* or the fit changes, so a re-fit is legible in the rows rather than silently
|
||||
* changing what they mean. Rows carrying an older version stay readable; the
|
||||
* surface skips a feature key the current build no longer knows.
|
||||
*/
|
||||
export const MODEL_VERSION = "mood-ridge-1";
|
||||
|
||||
/**
|
||||
* The penalties the validation searches.
|
||||
*
|
||||
* Never zero: with a dozen standardised columns over a few dozen days, an
|
||||
* unpenalised solve memorises the training days and describes no other day.
|
||||
* The grid is coarse on purpose — the difference between λ=8 and λ=10 is far
|
||||
* below the noise in thirty days of self-ratings, and a fine grid would only
|
||||
* be a slower way to overfit the validation set.
|
||||
*/
|
||||
export const LAMBDA_GRID = [0.5, 1, 2, 5, 10, 25, 50] as const;
|
||||
|
||||
/** How many validation blocks the window is cut into. */
|
||||
export const VALIDATION_FOLDS = 4;
|
||||
|
||||
/**
|
||||
* The band is the central 80% of the validation residuals: the 10th and the
|
||||
* 90th percentile. A range wide enough to be honest and narrow enough to be
|
||||
* worth reading — and empirical, so an asymmetric error stays asymmetric
|
||||
* instead of being averaged into a symmetric claim.
|
||||
*/
|
||||
export const BAND_LOWER_QUANTILE = 0.1;
|
||||
export const BAND_UPPER_QUANTILE = 0.9;
|
||||
|
||||
/** Fewest rows a fold split is attempted on at all. */
|
||||
export const MIN_VALIDATION_ROWS = 10;
|
||||
|
||||
/** One expanding-window split. Every index is into the time-ordered matrix. */
|
||||
export interface RollingOriginFold {
|
||||
/** Training rows are `[0, trainEnd)`. */
|
||||
trainEnd: number;
|
||||
/** Validation rows are `[validationStart, validationEnd)`. */
|
||||
validationStart: number;
|
||||
validationEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut a time-ordered window into expanding-window folds.
|
||||
*
|
||||
* `trainEnd === validationStart` in every fold, which is the whole invariant:
|
||||
* the training range is a prefix and the validation range starts exactly where
|
||||
* it ends. No gap (a gap would waste days), no overlap (an overlap is the
|
||||
* leak).
|
||||
*/
|
||||
export function rollingOriginFolds(
|
||||
rowCount: number,
|
||||
folds: number = VALIDATION_FOLDS,
|
||||
): RollingOriginFold[] {
|
||||
if (rowCount < MIN_VALIDATION_ROWS || folds < 1) return [];
|
||||
const block = Math.floor(rowCount / (folds + 1));
|
||||
if (block < 1) return [];
|
||||
|
||||
const out: RollingOriginFold[] = [];
|
||||
for (let index = 1; index <= folds; index++) {
|
||||
const validationStart = block * index;
|
||||
const validationEnd =
|
||||
index === folds ? rowCount : Math.min(rowCount, block * (index + 1));
|
||||
if (validationEnd <= validationStart) continue;
|
||||
out.push({ trainEnd: validationStart, validationStart, validationEnd });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Linear-interpolated quantile of an unsorted sample. */
|
||||
export function quantile(values: readonly number[], q: number): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const position = (sorted.length - 1) * Math.min(1, Math.max(0, q));
|
||||
const lower = Math.floor(position);
|
||||
const upper = Math.ceil(position);
|
||||
if (lower === upper) return sorted[lower];
|
||||
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
|
||||
}
|
||||
|
||||
/** What one penalty scored across every fold. */
|
||||
export interface LambdaScore {
|
||||
lambda: number;
|
||||
/** Residuals (`actual − predicted`) from validation rows only. */
|
||||
residuals: number[];
|
||||
meanAbsoluteError: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score one penalty over the folds. Training folds never contain a row dated
|
||||
* after their validation fold, by construction of {@link rollingOriginFolds}.
|
||||
*/
|
||||
export function scoreLambda(
|
||||
matrix: FeatureMatrix,
|
||||
lambda: number,
|
||||
folds: readonly RollingOriginFold[],
|
||||
): LambdaScore | null {
|
||||
const residuals: number[] = [];
|
||||
for (const fold of folds) {
|
||||
const fit = fitRidge(
|
||||
matrix.rows.slice(0, fold.trainEnd),
|
||||
matrix.targets.slice(0, fold.trainEnd),
|
||||
lambda,
|
||||
);
|
||||
if (fit === null) continue;
|
||||
for (let i = fold.validationStart; i < fold.validationEnd; i++) {
|
||||
const predicted = predictWithFit(fit, matrix.rows[i]);
|
||||
if (predicted === null) continue;
|
||||
residuals.push(matrix.targets[i] - predicted);
|
||||
}
|
||||
}
|
||||
if (residuals.length === 0) return null;
|
||||
return {
|
||||
lambda,
|
||||
residuals,
|
||||
meanAbsoluteError:
|
||||
residuals.reduce((sum, r) => sum + Math.abs(r), 0) / residuals.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** A model, ready to score a day, with the band its own errors earned it. */
|
||||
export interface PrognosisModel {
|
||||
fit: RidgeFit;
|
||||
features: string[];
|
||||
standardisation: FeatureMatrix["standardisation"];
|
||||
/** Rows the final fit used. This is the `n` every surface must show. */
|
||||
n: number;
|
||||
/** Offsets from a prediction to the edges of the band, in target units. */
|
||||
bandLow: number;
|
||||
bandHigh: number;
|
||||
/** Mean absolute error over the validation folds, for the record. */
|
||||
meanAbsoluteError: number;
|
||||
modelVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, choose λ, refit on the whole window, and take the band from the
|
||||
* validation residuals.
|
||||
*
|
||||
* `null` is a refusal and is a normal outcome: too few complete days to split,
|
||||
* a singular system, or a fold set that produced no residual at all. The
|
||||
* caller writes no forecast rather than a forecast nobody measured.
|
||||
*/
|
||||
export function fitPrognosisModel(
|
||||
matrix: FeatureMatrix,
|
||||
lambdaGrid: readonly number[] = LAMBDA_GRID,
|
||||
): PrognosisModel | null {
|
||||
const folds = rollingOriginFolds(matrix.rows.length);
|
||||
if (folds.length === 0) return null;
|
||||
|
||||
let best: LambdaScore | null = null;
|
||||
for (const lambda of lambdaGrid) {
|
||||
const score = scoreLambda(matrix, lambda, folds);
|
||||
if (score === null) continue;
|
||||
if (best === null || score.meanAbsoluteError < best.meanAbsoluteError) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
if (best === null) return null;
|
||||
|
||||
const fit = fitRidge(matrix.rows, matrix.targets, best.lambda);
|
||||
if (fit === null) return null;
|
||||
|
||||
return {
|
||||
fit,
|
||||
features: matrix.features,
|
||||
standardisation: matrix.standardisation,
|
||||
n: matrix.rows.length,
|
||||
bandLow: quantile(best.residuals, BAND_LOWER_QUANTILE),
|
||||
bandHigh: quantile(best.residuals, BAND_UPPER_QUANTILE),
|
||||
meanAbsoluteError: best.meanAbsoluteError,
|
||||
modelVersion: MODEL_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -172,6 +172,7 @@ const fakeDb = vi.hoisted(() => {
|
||||
ecgRecording: { findFirst: async () => null },
|
||||
personalRecord: { findMany: none },
|
||||
arrivalReaction: { findMany: none },
|
||||
encounter: { findFirst: async () => null },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock("@/lib/db", () => ({
|
||||
dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -126,6 +126,21 @@ export interface DailyDigestPreventiveDue {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked visit falling today or tomorrow.
|
||||
*
|
||||
* Read off the VISIT table, never off the reminder engine: an appointment's
|
||||
* one-shot reminder is excluded from every preventive-care read, and this is
|
||||
* how the appointment reaches the rail without relaxing that exclusion. The
|
||||
* kind rides as the enum constant so the reader's own bundle names it.
|
||||
*/
|
||||
export interface DailyDigestUpcomingVisit {
|
||||
id: string;
|
||||
kind: string;
|
||||
occurredAt: string;
|
||||
practitionerName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* S11 — a confident elevated-at-rest ("tension") window for the day, already
|
||||
* detected server-side (`loadIntradayPulse`) under its full confidence gate.
|
||||
@@ -251,6 +266,8 @@ export interface DailyDigestInput {
|
||||
morningRefreshedToday: boolean;
|
||||
syncIssues: DailyDigestSyncIssue[];
|
||||
preventiveDue: DailyDigestPreventiveDue[];
|
||||
/** The booked visit falling today or tomorrow, or null. Honest-absent. */
|
||||
upcomingVisit?: DailyDigestUpcomingVisit | null;
|
||||
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
|
||||
coachPlans: DailyDigestCoachPlan[];
|
||||
/**
|
||||
@@ -504,6 +521,44 @@ function buildPreventiveCareItem(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The booked visit falling today or tomorrow, as one rail line.
|
||||
*
|
||||
* No module gate: a visit is core, like the checkups page it lives on. No
|
||||
* action beyond opening the page either — there is nothing to do about an
|
||||
* appointment except keep it, and a "mark done" here would duplicate the
|
||||
* visit's own status field with a second place to set it.
|
||||
*/
|
||||
function buildUpcomingVisitItem(
|
||||
visit: DailyDigestUpcomingVisit | null,
|
||||
now: Date,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!visit) return null;
|
||||
const at = new Date(visit.occurredAt);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
// Today vs tomorrow is decided on the SAME instants the read window used, so
|
||||
// a visit admitted by the query can never be described as neither.
|
||||
const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000);
|
||||
const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`);
|
||||
return {
|
||||
kind: "upcoming_visit",
|
||||
title: t("daily.item.upcomingVisit.title"),
|
||||
body:
|
||||
hoursAway <= 24
|
||||
? t("daily.item.upcomingVisit.bodyToday", { what })
|
||||
: t("daily.item.upcomingVisit.bodyTomorrow", { what }),
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
|
||||
* state was reached TODAY (the IO seam already applied the reached-once gate).
|
||||
@@ -880,6 +935,16 @@ export function buildDailyDigest(
|
||||
if (ecg) worthALook.push(ecg);
|
||||
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
|
||||
if (preventive) worthALook.push(preventive);
|
||||
// Beside the checkups rather than among them: the two are neighbours on the
|
||||
// rail because a person reads "what am I meant to arrange" and "where am I
|
||||
// meant to be" in one glance, and they stay separate items because one is
|
||||
// overdue and the other is simply scheduled.
|
||||
const visit = buildUpcomingVisitItem(
|
||||
input.upcomingVisit ?? null,
|
||||
input.now,
|
||||
t,
|
||||
);
|
||||
if (visit) worthALook.push(visit);
|
||||
// S11 — the calm, informational tension marker sits last: it is context, not
|
||||
// an action that expires, so a time-sensitive dose / sync / check-in wins the
|
||||
// bounded rail ahead of it.
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type DailyDigestCoachPlan,
|
||||
type DailyDigestEcg,
|
||||
type DailyDigestPreventiveDue,
|
||||
type DailyDigestUpcomingVisit,
|
||||
type DailyDigestScore,
|
||||
type DailyDigestSameTime,
|
||||
type DailyDigestSyncIssue,
|
||||
@@ -63,6 +64,16 @@ import {
|
||||
/** Integration states that mean "your action is needed to keep data flowing". */
|
||||
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
|
||||
/**
|
||||
* How far ahead the rail mentions a booked appointment: today or tomorrow.
|
||||
*
|
||||
* The rail is a "what about today" surface, so anything further out is not a
|
||||
* thing to act on this morning. Two days rather than one because an evening
|
||||
* reader wants tomorrow's 08:00 appointment, and a 24-hour window would hide it
|
||||
* from them.
|
||||
*/
|
||||
const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Defensive cap on how many overdue reminders we read for the rail summary. */
|
||||
const PREVENTIVE_DUE_READ_LIMIT = 20;
|
||||
|
||||
@@ -355,6 +366,7 @@ export async function loadDailyDigest(
|
||||
planRows,
|
||||
ecgRow,
|
||||
arrivalRows,
|
||||
nextVisitRow,
|
||||
] = await Promise.all([
|
||||
readDashboardSnapshotCached(user),
|
||||
resolveModuleMap(user.id),
|
||||
@@ -428,6 +440,27 @@ export async function loadDailyDigest(
|
||||
generatedAt: true,
|
||||
},
|
||||
}),
|
||||
// The next PLANNED visit inside the two-day horizon. Read straight off the
|
||||
// visit table, never off the reminder engine — see the mapping below for
|
||||
// why the exclusion above must stay untouched.
|
||||
prisma.encounter.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: "PLANNED",
|
||||
occurredAt: {
|
||||
gt: now,
|
||||
lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS),
|
||||
},
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
occurredAt: true,
|
||||
practitioner: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const score: DailyDigestScore | null = snapshot.healthScore
|
||||
@@ -463,6 +496,29 @@ export async function loadDailyDigest(
|
||||
label: row.label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The appointment falling today or tomorrow, read from the VISIT table.
|
||||
*
|
||||
* Not from the reminder table. An ENCOUNTER-origin reminder is excluded from
|
||||
* every preventive-care read, which is right — an appointment is not a
|
||||
* checkup — but a notification about something the app never shows is worse
|
||||
* than none. So the appointment reaches the rail from the record it lives
|
||||
* in, and the exclusion above stays exactly as it is. A diff here that
|
||||
* touches `origin` is the wrong fix.
|
||||
*
|
||||
* `occurredAt > now` deliberately, not `>= todayStart`: an appointment that
|
||||
* has already happened today is not something to be reminded about, and the
|
||||
* person is more likely to be at it than looking at this.
|
||||
*/
|
||||
const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow
|
||||
? {
|
||||
id: nextVisitRow.id,
|
||||
kind: nextVisitRow.kind,
|
||||
occurredAt: nextVisitRow.occurredAt.toISOString(),
|
||||
practitionerName: nextVisitRow.practitioner?.name ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
|
||||
// builder decides "new" and gates on the `insights` module. An ECG row only
|
||||
// ever carries an AFib-screening verdict; the shared enum's other members
|
||||
@@ -563,6 +619,7 @@ export async function loadDailyDigest(
|
||||
morningRefreshedToday,
|
||||
syncIssues,
|
||||
preventiveDue,
|
||||
upcomingVisit,
|
||||
coachPlans,
|
||||
milestone,
|
||||
tensionWindow,
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PRIORITY_ITEM_KINDS = [
|
||||
"ecg_new_recording",
|
||||
"tension_window",
|
||||
"same_time_baseline",
|
||||
"upcoming_visit",
|
||||
] as const;
|
||||
|
||||
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
|
||||
@@ -52,7 +53,7 @@ export function isPriorityItemKind(value: unknown): value is PriorityItemKind {
|
||||
* action; the user can only
|
||||
* acknowledge them, so they're the only kinds the Today rail lets you
|
||||
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
|
||||
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
|
||||
* `preventive_care`, `coach_checkin`, `upcoming_visit`) are ACTIONABLE — they clear on their
|
||||
* own once the user acts (logs the dose, reconnects the integration, …) and
|
||||
* are never dismissible; offering a dismiss on them would just let a user
|
||||
* silence a still-open action item.
|
||||
|
||||
@@ -75,6 +75,12 @@ export const WIPE_MODELS = [
|
||||
// but a parent deleted first leaves the child's own count reporting zero for
|
||||
// rows that existed, and the confirmation screen quotes those counts.
|
||||
"MoodContext",
|
||||
// Derived, and wiped anyway. The forecast rows are recomputable from the
|
||||
// entries above — which is why they stay out of the backup — but they are
|
||||
// still a day-by-day statement about somebody's mood, and leaving them
|
||||
// behind after a screen that promised to delete all health data would leave
|
||||
// the shape of the record standing where the record used to be.
|
||||
"MoodPrediction",
|
||||
"MoodEntry",
|
||||
"MoodEntryRollup",
|
||||
"MoodReminderDispatch",
|
||||
|
||||
@@ -217,6 +217,73 @@ export function buildClinicalRecordsNotesSection(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// Visits inside the window — what happened at a practice, and what came out
|
||||
// of it. Present only when the VISITS leaf was selected AND the window held a
|
||||
// visit (the aggregator gates `data.visits`); the leaf carries no module of
|
||||
// its own, because a visit is core and the whole report is already gated by
|
||||
// `doctorReport`. Same calm factual register as the illness table above: no
|
||||
// colour, no status tint. The encrypted reason and outcome decrypt at render
|
||||
// and never enter the stored selection.
|
||||
if (data.visits && data.visits.length > 0) {
|
||||
y = ensureSpace(y, 6 + 18);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.visitsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const visitRows = data.visits.map((visit) => [
|
||||
fmtDate(visit.occurredAt),
|
||||
// The practice, with its specialty when there is one; an em dash when
|
||||
// the visit named neither, which is honest rather than invented.
|
||||
[visit.practitionerName, visit.practitionerSpecialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "—",
|
||||
t(`encounters.kind.${visit.kind}`),
|
||||
visit.reason ?? "—",
|
||||
visit.outcome ?? "—",
|
||||
visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—",
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.visitsColDate"),
|
||||
t("doctorReport.visitsColPractice"),
|
||||
t("doctorReport.visitsColKind"),
|
||||
t("doctorReport.visitsColReason"),
|
||||
t("doctorReport.visitsColOutcome"),
|
||||
t("doctorReport.visitsColConditions"),
|
||||
],
|
||||
],
|
||||
body: visitRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.27.x — structured allergy / intolerance records. Reference data
|
||||
// (not time-windowed) the aggregator populates when the `allergies`
|
||||
// toggle is ON (default) and rows exist. Stored fields only — substance,
|
||||
|
||||
@@ -274,6 +274,28 @@ export interface DoctorReportData {
|
||||
* module is off or no episode overlaps the window; both builders then skip
|
||||
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
|
||||
*/
|
||||
/**
|
||||
* Visits inside the window — the contacts with the healthcare system this
|
||||
* period held. Null when the leaf was not selected or none fell inside the
|
||||
* window; both builders then skip the section. Optional so pre-v1.38
|
||||
* fixtures still typecheck.
|
||||
*
|
||||
* `kind` rides as the enum constant: the renderer names it in the REPORT's
|
||||
* language, which is the reading clinician's, not whoever generated it.
|
||||
* `reason` and `outcome` are the decrypted free text and are null on a
|
||||
* key-rotation gap — an absence rather than a fabricated line.
|
||||
*/
|
||||
visits?: Array<{
|
||||
occurredAt: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
practitionerName: string | null;
|
||||
practitionerSpecialty: string | null;
|
||||
reason: string | null;
|
||||
outcome: string | null;
|
||||
/** Labels of the conditions this visit was filed against. */
|
||||
conditionLabels: string[];
|
||||
}> | null;
|
||||
illnessEpisodes?: Array<{
|
||||
/** User-facing condition label (e.g. "Erkältung"). */
|
||||
label: string;
|
||||
|
||||
@@ -216,6 +216,17 @@ function seed() {
|
||||
resolvedAt: new Date("2026-01-20T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
rows.encounter = [
|
||||
{
|
||||
occurredAt: new Date("2026-02-05T09:00:00.000Z"),
|
||||
kind: "SPECIALIST",
|
||||
status: "DONE",
|
||||
reasonEncrypted: null,
|
||||
outcomeEncrypted: null,
|
||||
practitioner: { name: "Sample practice", specialty: "Cardiology" },
|
||||
conditionLinks: [],
|
||||
},
|
||||
];
|
||||
rows.allergy = [
|
||||
{
|
||||
substance: "Penicillin",
|
||||
@@ -262,6 +273,7 @@ describe("per-leaf gating sweep", () => {
|
||||
expect(allPayload.allergies).not.toBeNull();
|
||||
expect(allPayload.familyHistory).not.toBeNull();
|
||||
expect(allPayload.illnessEpisodes).not.toBeNull();
|
||||
expect(allPayload.visits).not.toBeNull();
|
||||
expect(allPayload.mood).not.toBeNull();
|
||||
expect(allPayload.cycle).not.toBeNull();
|
||||
expect(allPayload.anamnesis).not.toBeNull();
|
||||
@@ -415,6 +427,7 @@ describe("zero read for unchosen leaves", () => {
|
||||
["ALLERGIES", "allergy"],
|
||||
["LAB_RESULTS", "labResult"],
|
||||
["ILLNESS_EPISODES", "illnessEpisode"],
|
||||
["VISITS", "encounter"],
|
||||
["ANAMNESIS", "userHealthProfile"],
|
||||
["ANAMNESIS", "healthProfileFactRevision"],
|
||||
];
|
||||
@@ -474,6 +487,7 @@ describe("the empty selection serves nothing", () => {
|
||||
expect(payload.allergies).toBeNull();
|
||||
expect(payload.familyHistory).toBeNull();
|
||||
expect(payload.illnessEpisodes).toBeNull();
|
||||
expect(payload.visits).toBeNull();
|
||||
expect(payload.anamnesis).toBeNull();
|
||||
expect(payload.cycle).toBeNull();
|
||||
expect(payload.mood).toBeNull();
|
||||
|
||||
@@ -122,6 +122,78 @@ export async function loadIllnessEpisodes(
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits inside the window, with the conditions each was filed against.
|
||||
*
|
||||
* The two free-text columns are `Bytes` ciphertext and decrypt fail-soft, the
|
||||
* same stance the allergy reaction takes: a key-rotation gap on one row reads
|
||||
* as a visit with no reason recorded rather than as a failed report. `kind` and
|
||||
* `status` cross as enum constants — the renderer names them in the REPORT's
|
||||
* language, which is the reading clinician's.
|
||||
*
|
||||
* The condition labels come off the link rows directly rather than through the
|
||||
* link service: this is a read of the OWNER's own record for the owner's own
|
||||
* report, with no grant to narrow against and no redaction to apply, and the
|
||||
* service's per-family visibility predicate has no meaning here.
|
||||
*/
|
||||
export async function loadVisits(
|
||||
userId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<DoctorReportData["visits"]> {
|
||||
const rows = await prisma.encounter.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: start, lte: end },
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: {
|
||||
occurredAt: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
reasonEncrypted: true,
|
||||
outcomeEncrypted: true,
|
||||
practitioner: { select: { name: true, specialty: true } },
|
||||
conditionLinks: {
|
||||
select: { episode: { select: { label: true, deletedAt: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const decrypt = (
|
||||
value: Uint8Array | null,
|
||||
field: "reason" | "outcome",
|
||||
): string | null => {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(value);
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
`doctor-report: visit ${field} decrypt failed for ${userId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const mapped = rows.map((row) => ({
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
practitionerSpecialty: row.practitioner?.specialty ?? null,
|
||||
reason: decrypt(row.reasonEncrypted, "reason"),
|
||||
outcome: decrypt(row.outcomeEncrypted, "outcome"),
|
||||
// A condition the person has since deleted is not named in a clinical
|
||||
// document: the link row survives the soft delete, and printing a label
|
||||
// they removed would put a withdrawn diagnosis in front of a doctor.
|
||||
conditionLabels: row.conditionLinks
|
||||
.filter((link) => link.episode?.deletedAt === null)
|
||||
.map((link) => link.episode.label),
|
||||
}));
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured allergy records. Reference data, not time-windowed — a
|
||||
* penicillin allergy does not expire with the report window.
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
loadAnamnesis,
|
||||
loadFamilyHistory,
|
||||
loadIllnessEpisodes,
|
||||
loadVisits,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
|
||||
@@ -498,6 +499,7 @@ export async function collectDoctorReportData(
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
allergies,
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
@@ -511,6 +513,9 @@ export async function collectDoctorReportData(
|
||||
gate.admits("ILLNESS_EPISODES")
|
||||
? loadIllnessEpisodes(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("VISITS")
|
||||
? loadVisits(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
|
||||
gate.admits("FAMILY_HISTORY")
|
||||
? loadFamilyHistory(userId)
|
||||
@@ -579,6 +584,7 @@ export async function collectDoctorReportData(
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
allergies,
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listTargetsBySource, replaceTargets } from "@/lib/links";
|
||||
import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents";
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
DocumentEncounterLinkDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Load the condition links for a page of documents in ONE grouped query.
|
||||
@@ -39,6 +42,82 @@ export async function loadConditionLinks(
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the VISIT links for a page of documents in ONE grouped query.
|
||||
*
|
||||
* Unlike the condition chips this filters tombstoned visits out: the condition
|
||||
* behaviour is a long-standing one this refactor deliberately did not change,
|
||||
* while a visit link is new and starts from the honest default — a deleted
|
||||
* visit is not something the document still belongs to.
|
||||
*/
|
||||
export async function loadDocumentEncounterLinks(
|
||||
userId: string,
|
||||
documentIds: string[],
|
||||
): Promise<Map<string, DocumentEncounterLinkDto[]>> {
|
||||
const map = new Map<string, DocumentEncounterLinkDto[]>();
|
||||
if (documentIds.length === 0) return map;
|
||||
const byDocument = await listTargetsBySource(prisma, {
|
||||
userId,
|
||||
sourceKind: "document",
|
||||
sourceIds: documentIds,
|
||||
targetKind: "encounter",
|
||||
});
|
||||
for (const [documentId, targets] of byDocument) {
|
||||
map.set(
|
||||
documentId,
|
||||
targets.map((target) => ({
|
||||
encounterId: target.id,
|
||||
kind: target.label,
|
||||
occurredAt: target.date,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a client-sent visit-id list to the caller's LIVE visits.
|
||||
*
|
||||
* Same shape and same refusal as {@link narrowOwnedEpisodeIds}: null when any
|
||||
* id names nothing, so an attacker probing another account's visit ids learns
|
||||
* only "not found".
|
||||
*/
|
||||
export async function narrowOwnedEncounterIds(
|
||||
userId: string,
|
||||
encounterIds: string[],
|
||||
): Promise<string[] | null> {
|
||||
const unique = [...new Set(encounterIds)];
|
||||
if (unique.length === 0) return [];
|
||||
const owned = await prisma.encounter.findMany({
|
||||
where: { id: { in: unique }, userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (owned.length !== unique.length) return null;
|
||||
return unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace-set a document's visit links inside the given client.
|
||||
*
|
||||
* Goes through the link service in the `document → encounter` direction, which
|
||||
* is the same table the visit's own sheet writes from the other end, so the two
|
||||
* surfaces cannot disagree about what is filed where.
|
||||
*/
|
||||
export async function replaceEncounterLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
documentId: string,
|
||||
encounterIds: string[],
|
||||
): Promise<void> {
|
||||
await replaceTargets(tx, {
|
||||
userId,
|
||||
sourceKind: "document",
|
||||
sourceId: documentId,
|
||||
targetKind: "encounter",
|
||||
targetIds: encounterIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a client-sent episode-id list to the caller's LIVE episodes.
|
||||
* Returns the deduplicated owned ids, or null when any id is unknown /
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
DocumentEncounterLinkDto,
|
||||
DocumentContentIndexSourceValue,
|
||||
DocumentSummaryStateValue,
|
||||
ExtractedFactDto,
|
||||
@@ -199,6 +200,7 @@ export function serialiseDocument(
|
||||
hasContentIndex = false,
|
||||
contentIndexSource: DocumentContentIndexSourceValue | null = null,
|
||||
hasThumbnail = false,
|
||||
encounterLinks: DocumentEncounterLinkDto[] = [],
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id: doc.id,
|
||||
@@ -219,6 +221,7 @@ export function serialiseDocument(
|
||||
factCount: counts.factCount,
|
||||
pendingCount: counts.pendingCount,
|
||||
conditionLinks,
|
||||
encounterLinks,
|
||||
servingClass: servingClassFor(doc.mimeType),
|
||||
hasContentIndex,
|
||||
contentIndexSource: hasContentIndex ? contentIndexSource : null,
|
||||
@@ -251,6 +254,7 @@ export function serialiseDocumentDetail(
|
||||
hasContentIndex = false,
|
||||
contentIndexSource: DocumentContentIndexSourceValue | null = null,
|
||||
hasThumbnail = false,
|
||||
encounterLinks: DocumentEncounterLinkDto[] = [],
|
||||
): InboundDocumentDetailDto {
|
||||
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
|
||||
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
|
||||
@@ -286,6 +290,7 @@ export function serialiseDocumentDetail(
|
||||
hasContentIndex,
|
||||
contentIndexSource,
|
||||
hasThumbnail,
|
||||
encounterLinks,
|
||||
),
|
||||
facts: facts.map(serialiseFact),
|
||||
summary,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* The suggestion rule, pinned.
|
||||
*
|
||||
* Two properties carry the whole feature and both are easy to break without
|
||||
* noticing:
|
||||
*
|
||||
* - the window is ±7 days and not "recently";
|
||||
* - two candidates offer a picker with nothing pre-selected, and never the
|
||||
* first one.
|
||||
*
|
||||
* Every fixture is RELATIVE to the moment the test runs (`now − 3 days`, never
|
||||
* `2026-03-14`). A fixed-date fixture slides out of a relative window as the
|
||||
* calendar moves and the test rots into a false green; this repository has
|
||||
* shipped that bug before.
|
||||
*
|
||||
* The fake below honours the `where` it is given. A fake that ignores it would
|
||||
* return every seeded row for every query, so the window assertions would pass
|
||||
* against a module with no window at all — a check that cannot fail is worse
|
||||
* than none.
|
||||
*
|
||||
* Mutation checks (both run, both seen red):
|
||||
* - widen `ENCOUNTER_SUGGEST_WINDOW_DAYS` to 90 → the out-of-window cases go
|
||||
* red naming the 30-day visit that started matching;
|
||||
* - make the two-candidate branch return `{ kind: "one" }` with the first row
|
||||
* → "offers a picker with nothing pre-selected" goes red.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
ENCOUNTER_SUGGEST_WINDOW_DAYS,
|
||||
suggestEncounterForDate,
|
||||
} from "../suggest-window";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
userId: string;
|
||||
occurredAt: Date;
|
||||
status: string;
|
||||
kind: string;
|
||||
deletedAt: Date | null;
|
||||
practitioner: { name: string } | null;
|
||||
}
|
||||
|
||||
function row(over: Partial<Row> & { id: string; occurredAt: Date }): Row {
|
||||
return {
|
||||
userId: "u1",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
deletedAt: null,
|
||||
practitioner: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory `encounter.findMany` that actually applies the filter.
|
||||
*
|
||||
* It evaluates every clause the module writes — the record id, the tombstone,
|
||||
* the status set and both window edges — so a module that dropped one of them
|
||||
* returns rows this fake would have excluded, and the assertion below fails.
|
||||
*/
|
||||
function fakeTx(rows: Row[]): Prisma.TransactionClient {
|
||||
return {
|
||||
encounter: {
|
||||
findMany: async (args: {
|
||||
where: {
|
||||
userId: string;
|
||||
deletedAt: null;
|
||||
status: { in: string[] };
|
||||
occurredAt: { gte: Date; lte: Date };
|
||||
};
|
||||
take?: number;
|
||||
}) => {
|
||||
const { where } = args;
|
||||
const matched = rows
|
||||
.filter((r) => r.userId === where.userId)
|
||||
.filter((r) =>
|
||||
where.deletedAt === null ? r.deletedAt === null : true,
|
||||
)
|
||||
.filter((r) => where.status.in.includes(r.status))
|
||||
.filter(
|
||||
(r) =>
|
||||
r.occurredAt.getTime() >= where.occurredAt.gte.getTime() &&
|
||||
r.occurredAt.getTime() <= where.occurredAt.lte.getTime(),
|
||||
)
|
||||
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
|
||||
return matched.slice(0, args.take ?? matched.length);
|
||||
},
|
||||
},
|
||||
} as unknown as Prisma.TransactionClient;
|
||||
}
|
||||
|
||||
const anchor = new Date();
|
||||
const ago = (days: number, extraMs = 0) =>
|
||||
new Date(anchor.getTime() - days * DAY_MS - extraMs);
|
||||
const ahead = (days: number, extraMs = 0) =>
|
||||
new Date(anchor.getTime() + days * DAY_MS + extraMs);
|
||||
|
||||
describe("the window", () => {
|
||||
it("is seven days wide on each side", () => {
|
||||
expect(ENCOUNTER_SUGGEST_WINDOW_DAYS).toBe(7);
|
||||
});
|
||||
|
||||
it("admits a visit exactly seven days before the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(7) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
kind: "one",
|
||||
encounter: expect.objectContaining({ id: "e1" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("admits a visit exactly seven days after the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ahead(7) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
kind: "one",
|
||||
encounter: expect.objectContaining({ id: "e1" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a visit seven days and one minute before the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(7, MINUTE_MS) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("refuses a visit seven days and one minute after the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ahead(7, MINUTE_MS) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("refuses a visit thirty days out", async () => {
|
||||
// The widened-window mutation lands here first: at ±90 days this row
|
||||
// starts matching and the verdict stops being "none".
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "far", occurredAt: ahead(30) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the verdict", () => {
|
||||
it("offers nothing when there is no candidate", async () => {
|
||||
const result = await suggestEncounterForDate(fakeTx([]), {
|
||||
userId: "u1",
|
||||
anchor,
|
||||
});
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("pre-selects the single candidate, resolved", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([
|
||||
row({
|
||||
id: "e1",
|
||||
occurredAt: ago(3),
|
||||
kind: "SPECIALIST",
|
||||
practitioner: { name: "Praxis am Park" },
|
||||
}),
|
||||
]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
kind: "one",
|
||||
encounter: {
|
||||
id: "e1",
|
||||
occurredAt: ago(3).toISOString(),
|
||||
status: "DONE",
|
||||
kind: "SPECIALIST",
|
||||
practitionerName: "Praxis am Park",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a picker with nothing pre-selected for two candidates", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([
|
||||
row({ id: "e1", occurredAt: ago(3) }),
|
||||
row({ id: "e2", occurredAt: ago(1) }),
|
||||
]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
// The shape is the assertion: there is no field on a "many" verdict a
|
||||
// caller could read as a pre-selection.
|
||||
expect(result.kind).toBe("many");
|
||||
expect(result).not.toHaveProperty("encounter");
|
||||
if (result.kind !== "many") throw new Error("unreachable");
|
||||
expect(result.encounters.map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is never a candidate", () => {
|
||||
it("a soft-deleted visit", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(1), deletedAt: new Date() })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("a cancelled visit, and a no-show", async () => {
|
||||
// Neither happened, so neither produced the record being filed. Admitting
|
||||
// them would widen the set with rows that are wrong most of the time.
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([
|
||||
row({ id: "e1", occurredAt: ago(1), status: "CANCELLED" }),
|
||||
row({ id: "e2", occurredAt: ago(2), status: "NO_SHOW" }),
|
||||
]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("another account's visit", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(1), userId: "u2" })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("an unusable anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(1) })]),
|
||||
{ userId: "u1", anchor: new Date("not a date") },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,15 @@ export interface EncounterDTO {
|
||||
* happened, or once the one-shot reminder is spent.
|
||||
*/
|
||||
reminderNextDueAt: string | null;
|
||||
/** Present on the detail response; omitted from the list. */
|
||||
/**
|
||||
* The three link families, resolved.
|
||||
*
|
||||
* Present on the list as well as the detail. Omitting them from the list
|
||||
* would break two things at once: the card counts what a visit produced, and
|
||||
* the edit sheet seeds its pickers from the row it was handed — an edit
|
||||
* opened from a linkless row would save three empty arrays and unfile
|
||||
* everything. The list pays three grouped queries for the whole page.
|
||||
*/
|
||||
links?: EncounterLinksDTO;
|
||||
/**
|
||||
* What the write could not do, named.
|
||||
@@ -130,7 +138,8 @@ export function toEncounterDTO(
|
||||
* Two arrays rather than one sorted list, resolved server-side: "what is
|
||||
* coming up" and "what happened" are read in opposite directions, and making
|
||||
* the client split and re-sort one array would put that decision in two
|
||||
* places.
|
||||
* places. Each row carries its links, for the reasons stated at
|
||||
* `EncounterDTO.links`.
|
||||
*/
|
||||
export interface EncounterListDTO {
|
||||
upcoming: EncounterDTO[];
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
satisfyReminder,
|
||||
type SatisfiableReminder,
|
||||
} from "@/lib/measurement-reminders/satisfy";
|
||||
import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links";
|
||||
import {
|
||||
listTargets,
|
||||
listTargetsBySource,
|
||||
replaceTargets,
|
||||
type LinkTargetKind,
|
||||
} from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
|
||||
@@ -75,11 +80,21 @@ export async function actingDomainVisibility(
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
|
||||
/** Which section of the record each link family's TARGET belongs to. */
|
||||
/**
|
||||
* Which section of the record each link family's TARGET belongs to.
|
||||
*
|
||||
* Exhaustive over `LinkTargetKind` by the compiler, so a link kind added to
|
||||
* the service cannot reach a visit's link list without somebody deciding which
|
||||
* domain governs its names. `encounter` is here for that reason and is never
|
||||
* read from this side — a visit does not point at another visit — but a
|
||||
* `Partial` here would turn "nobody classified it" into "undefined", and an
|
||||
* undefined domain read as a falsy check is a leak.
|
||||
*/
|
||||
const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
|
||||
document: "documents",
|
||||
labResult: "labs",
|
||||
conditionEpisode: "illness",
|
||||
encounter: "profile",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -134,6 +149,71 @@ export async function loadEncounterLinks(
|
||||
return { documents, labResults, conditions };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same three families for a PAGE of visits, in three grouped queries.
|
||||
*
|
||||
* Two grouped reads per family rather than two per visit — `listTargetsBySource`
|
||||
* is the batched arm of the same service, so the list pays six round trips for
|
||||
* a page instead of six per row.
|
||||
*
|
||||
* The list carries links for two reasons that are both defects when it does
|
||||
* not. The card shows counts of what a visit produced, and a card reading zero
|
||||
* for a visit with three documents is a lie the person cannot see past. And the
|
||||
* edit sheet seeds its pickers from the row it was handed: an edit opened from a
|
||||
* list row that did not know its links would save a replace-set of three empty
|
||||
* arrays and silently unfile everything the visit had collected.
|
||||
*
|
||||
* Redaction is the same rule, applied per family, from the same predicate.
|
||||
*/
|
||||
export async function loadEncounterLinksForMany(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
encounterIds: string[],
|
||||
visible: (domain: ShareDomain) => boolean,
|
||||
): Promise<Map<string, EncounterLinksDTO>> {
|
||||
const out = new Map<string, EncounterLinksDTO>();
|
||||
if (encounterIds.length === 0) return out;
|
||||
|
||||
const read = async (targetKind: LinkTargetKind) =>
|
||||
listTargetsBySource(tx, {
|
||||
userId,
|
||||
sourceKind: "encounter",
|
||||
sourceIds: encounterIds,
|
||||
targetKind,
|
||||
});
|
||||
const [documents, labResults, conditions] = await Promise.all([
|
||||
read("document"),
|
||||
read("labResult"),
|
||||
read("conditionEpisode"),
|
||||
]);
|
||||
|
||||
const family = (
|
||||
map: Map<string, { id: string; label: string; date: string | null }[]>,
|
||||
id: string,
|
||||
targetKind: LinkTargetKind,
|
||||
) => {
|
||||
const rows = map.get(id) ?? [];
|
||||
if (visible(TARGET_DOMAIN[targetKind])) {
|
||||
return rows.map((row) => ({ ...row, redacted: false }));
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
label: null,
|
||||
date: null,
|
||||
redacted: true,
|
||||
}));
|
||||
};
|
||||
|
||||
for (const id of encounterIds) {
|
||||
out.set(id, {
|
||||
documents: family(documents, id, "document"),
|
||||
labResults: family(labResults, id, "labResult"),
|
||||
conditions: family(conditions, id, "conditionEpisode"),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply whichever of the three link arrays the request actually named.
|
||||
*
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user