Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:17:40 +02:00
commit 4c2dfa57b4
138 files changed
+12046 -137

No files matched your search

+18
View File
@@ -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
View File
@@ -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."
+102
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+63
View File
@@ -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,
+14 -2
View File
@@ -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) ?? [],
),
);
},
+33 -2
View File
@@ -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,
);
+48 -4
View File
@@ -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.
+77 -12
View File
@@ -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,
+22 -3
View File
@@ -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);
});
+59
View File
@@ -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(
+23 -1
View File
@@ -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
+17
View File
@@ -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);
+74
View File
@@ -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);
});
+20 -1
View File
@@ -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" },
+39 -6
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+21
View File
@@ -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" />
+2
View File
@@ -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,
+55 -3
View File
@@ -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 -2
View File
@@ -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}
+11 -1
View File
@@ -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)}
/>
</>
);
}
+127
View File
@@ -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)),
});
}
+27 -1
View File
@@ -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}
+32 -1
View File
@@ -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}
+8 -1
View File
@@ -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">
+138
View File
@@ -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 };
}
+72
View File
@@ -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,
};
}
+213
View File
@@ -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;
}
+231
View File
@@ -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),
};
}
+173
View File
@@ -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) },
},
}));
+65
View File
@@ -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.
+57
View File
@@ -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,
+2 -1
View File
@@ -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.
+6
View File
@@ -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,
+22
View File
@@ -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();
+72
View File
@@ -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.
+6
View File
@@ -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,
+80 -1
View File
@@ -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 /
+5
View File
@@ -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" });
});
});
+11 -2
View File
@@ -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[];
+82 -2
View File
@@ -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