mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #382 from MBombeck/release/v1.25.1
release: v1.25.1 — Documents library, tidier settings
This commit is contained in:
76 files changed
+7739
-1912
No files matched your search
@@ -27,6 +27,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
# Map the opt-in secret to a job-level env so the live-judge steps can gate
|
||||
# on it. The `secrets` context is NOT available in `if:` expressions — using
|
||||
# it there is a workflow-file validation error that surfaces as a 0-second
|
||||
# startup failure on every push. `env` is allowed in `if:`, so gate on that.
|
||||
env:
|
||||
COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
@@ -51,14 +58,12 @@ jobs:
|
||||
# non-blocking either way. With the secret absent the script no-ops with a
|
||||
# clear line and exits 0.
|
||||
- name: Live judge (opt-in, non-blocking)
|
||||
if: ${{ secrets.COACH_EVAL_API_KEY != '' }}
|
||||
if: ${{ env.COACH_EVAL_API_KEY != '' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }}
|
||||
run: pnpm dlx tsx scripts/run-coach-eval.ts
|
||||
|
||||
# When the secret is absent, surface a clear no-op line so the run log
|
||||
# explains why the live judge did not execute.
|
||||
- name: Live judge skipped (no secret)
|
||||
if: ${{ secrets.COACH_EVAL_API_KEY == '' }}
|
||||
if: ${{ env.COACH_EVAL_API_KEY == '' }}
|
||||
run: echo "COACH_EVAL_API_KEY not set — live Coach judge skipped (deterministic floor still gated above)."
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.25.1] — 2026-06-29 — Documents library, tidier settings
|
||||
|
||||
A patch release that turns the document inbox into a real library, reorganises the settings, closes a cross-account data-isolation gap on shared browsers, and carries a broad quality pass across the new surfaces. One additive migration (`0222`) applies automatically on start.
|
||||
|
||||
### Added
|
||||
|
||||
- A documents library: file any clinical document — a report, discharge letter, lab result, imaging, prescription, and more — without needing an AI provider. Give it a title, a category, and a date, then search, filter, sort, and browse it grouped by date. AI fact-extraction is now a separate, optional step you run on a document you have already filed, and you can view or download the original at any time.
|
||||
- Richer reference descriptions for every biomarker — a few plain sentences on what each marker measures and what it relates to, in all six languages.
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings are reorganised into nine clear groups — Account, Tracking, Display, Integrations, Notifications, AI & Coach, API & Access, Data & Privacy, and About — with in-page tabs. Far fewer entries down the left, and every page keeps its own address. Sign-in security — two-factor, passkeys, active sessions, and trusted devices — now lives together under Account.
|
||||
- "Inbound documents" is now simply "Documents".
|
||||
- Several repeated medical disclaimers were removed in favour of one clear, non-diagnostic statement, and the mental-wellbeing check-in now stands on its own page.
|
||||
- The daily briefing can draw on grip strength, waist, and pain alongside your other signals, and the grip-strength and waist reference bands are age- and sex-aware.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Signing out and back in as a different account on a shared browser no longer shows the previous account's data: the in-memory cache is now cleared at every sign-in and sign-out, alongside the offline caches.
|
||||
- A once-weekly medication no longer triggers a false "low adherence" alert — the alert now uses the same cadence-aware calculation as the rest of the app.
|
||||
- A lab value read from a filed document is no longer saved under the wrong unit: a unit mismatch is refused and reported rather than silently mis-recorded.
|
||||
- Moving between insight pages now starts at the top; scroll resets on navigation throughout the app.
|
||||
- A broad pass of accessibility, mobile-layout, date-format, and wording refinements across documents, settings, and the biomarker pages.
|
||||
|
||||
### Security
|
||||
|
||||
- Cross-account data isolation on a shared browser: no cached reading from one account can survive into another's session.
|
||||
|
||||
## [1.25.0] — 2026-06-28 — Clinical depth
|
||||
|
||||
A milestone release. It broadens tracking into clinical territory — validated mental-wellbeing self-assessments, grip strength, a pain score, waist measurements, a longevity lab panel, and respiratory rate as a first-class vital — adds structured allergy and family-history records, an optional weather-and-daylight context module, and the ability to file a doctor's letter and pull its facts into your record after you review them. It also makes the Coach's check-ins warmer, restores assistant-connector sign-in, and carries a broad correctness, performance, security, and accessibility pass. Additive migrations (`0208`–`0220`) apply automatically on start.
|
||||
|
||||
+303
-23
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.25.0
|
||||
version: 1.25.1
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -8595,12 +8595,82 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Documents
|
||||
summary: List inbound clinical documents
|
||||
description: The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on
|
||||
the opt-in `inboundDocuments` module.
|
||||
summary: List stored documents
|
||||
description: The caller's stored documents with title/filename search, category filter, a `documentDate` range, sort,
|
||||
and keyset pagination. Owner-scoped; gated on the opt-in `inboundDocuments` module.
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description: Case-insensitive search over title + filename.
|
||||
- name: kind
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
description: Category filter.
|
||||
- name: from
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Inclusive lower bound on documentDate (YYYY-MM-DD).
|
||||
- name: to
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Inclusive upper bound on documentDate (YYYY-MM-DD).
|
||||
- name: sort
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- documentDate
|
||||
- createdAt
|
||||
- title
|
||||
default: documentDate
|
||||
- name: order
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
default: desc
|
||||
- name: cursor
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Keyset cursor (the `nextCursor` from a prior page).
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 50
|
||||
responses:
|
||||
"200":
|
||||
description: The document list.
|
||||
description: A page of documents.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -8611,14 +8681,12 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Upload + extract a clinical document
|
||||
description: 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw
|
||||
document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type.
|
||||
VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus
|
||||
an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the
|
||||
extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what
|
||||
the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores
|
||||
here; the confirm route is the only write path.'
|
||||
summary: Store a document (no extraction)
|
||||
description: "STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction
|
||||
— provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider
|
||||
configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB)
|
||||
plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in
|
||||
action — see `POST /api/documents/inbound/{id}/extract`."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -8629,22 +8697,31 @@ paths:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.
|
||||
description: The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.
|
||||
title:
|
||||
description: Optional user label.
|
||||
type: string
|
||||
kind:
|
||||
description: Optional document-type label.
|
||||
description: Optional category.
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
documentDate:
|
||||
description: Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library
|
||||
sort/filter/display stay aligned.
|
||||
type: string
|
||||
required:
|
||||
- file
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundTextUploadRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: The created document + staging summary.
|
||||
description: The stored document.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -8674,10 +8751,60 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
patch:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Edit a document's metadata
|
||||
description: Rename / recategorise / set the user filing date on a stored document. Owner-scoped; no mass assignment.
|
||||
Returns the updated document + its staged facts.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 200
|
||||
- type: "null"
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
documentDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
- type: "null"
|
||||
responses:
|
||||
"200":
|
||||
description: The updated document.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundDocumentUpdateEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Discard an inbound document
|
||||
summary: Discard a document
|
||||
description: Soft-deletes the document + its staging. Facts already approved into the structured stores are independent
|
||||
rows and are NOT affected.
|
||||
parameters:
|
||||
@@ -8696,6 +8823,79 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/documents/inbound/{id}/original:
|
||||
get:
|
||||
tags:
|
||||
- Documents
|
||||
summary: View / download the original document
|
||||
description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under
|
||||
`InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are
|
||||
constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served
|
||||
`Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`.
|
||||
Fails closed (500) on a decrypt error — it never returns the ciphertext."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: The decrypted original document bytes with the stored MIME type.
|
||||
content:
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/documents/inbound/{id}/extract:
|
||||
post:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Extract facts from a stored document
|
||||
description: "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the
|
||||
stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider
|
||||
configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only
|
||||
the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original
|
||||
(PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }`
|
||||
structures browser-OCR'd text. Extraction reproduces what the document states — it never interprets. Nothing
|
||||
reaches the structured stores here; the confirm route is the only write path."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundTextExtractRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: The document with its newly staged facts.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundExtractEnvelope"
|
||||
"401": *a1
|
||||
"409":
|
||||
description: "Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` =
|
||||
`documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and
|
||||
duplicate committed records, so the user must finish reviewing or discard the document first."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/documents/inbound/{id}/facts/{factId}:
|
||||
patch:
|
||||
tags:
|
||||
@@ -10629,7 +10829,7 @@ components:
|
||||
required:
|
||||
- mode
|
||||
- text
|
||||
InboundTextUploadRequest:
|
||||
InboundTextExtractRequest:
|
||||
type: object
|
||||
properties:
|
||||
mode:
|
||||
@@ -10642,6 +10842,11 @@ components:
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
required:
|
||||
- mode
|
||||
@@ -24907,9 +25112,16 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InboundDocument"
|
||||
nextCursor:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- documents
|
||||
- nextCursor
|
||||
additionalProperties: false
|
||||
description: A page of documents plus an opaque `nextCursor` (the id to pass back as `cursor` for the next page; null
|
||||
when the list is exhausted).
|
||||
InboundDocument:
|
||||
type: object
|
||||
properties:
|
||||
@@ -24920,7 +25132,16 @@ components:
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
title:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
filename:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -24932,6 +25153,7 @@ components:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- STORED
|
||||
- EXTRACTING
|
||||
- EXTRACTED
|
||||
- FAILED
|
||||
@@ -24945,6 +25167,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
documentDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
errorReason:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -24960,20 +25186,24 @@ components:
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- title
|
||||
- filename
|
||||
- mimeType
|
||||
- byteSize
|
||||
- status
|
||||
- providerType
|
||||
- reportDate
|
||||
- documentDate
|
||||
- errorReason
|
||||
- factCount
|
||||
- pendingCount
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the
|
||||
metadata + staging summary.
|
||||
description: A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata +
|
||||
staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label
|
||||
(plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until
|
||||
extraction runs).
|
||||
InboundDocumentEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -25018,7 +25248,16 @@ components:
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
title:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
filename:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -25030,6 +25269,7 @@ components:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- STORED
|
||||
- EXTRACTING
|
||||
- EXTRACTED
|
||||
- FAILED
|
||||
@@ -25043,6 +25283,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
documentDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
errorReason:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -25062,12 +25306,14 @@ components:
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- title
|
||||
- filename
|
||||
- mimeType
|
||||
- byteSize
|
||||
- status
|
||||
- providerType
|
||||
- reportDate
|
||||
- documentDate
|
||||
- errorReason
|
||||
- factCount
|
||||
- pendingCount
|
||||
@@ -25140,6 +25386,23 @@ components:
|
||||
with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are
|
||||
present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact
|
||||
scored below the confidence floor (it cannot be approved until edited).
|
||||
InboundDocumentUpdateEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InboundDocumentDetail"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InboundDiscardEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -25166,6 +25429,23 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InboundExtractEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InboundDocumentDetail"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ExtractedFactEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -24,14 +24,35 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.fixme("returning to /insights from a sub-page lands at the top", async ({
|
||||
test("returning to /insights from a sub-page lands at the top", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// v1.25.1 — the app-wide scroll-to-top resets the `#main-content`
|
||||
// scroll container on every route change (auth-shell), with a
|
||||
// `window.scrollTo(0,0)` fallback for the body-scrolled shells. The
|
||||
// authenticated insights surface scrolls `#main-content`, so the
|
||||
// regression lock reads that container, not `window`.
|
||||
const readScrollTop = () =>
|
||||
page.evaluate(() => {
|
||||
const el = document.getElementById("main-content");
|
||||
return el && el.scrollHeight > el.clientHeight
|
||||
? el.scrollTop
|
||||
: window.scrollY;
|
||||
});
|
||||
|
||||
// Force the mother page tall enough that scrolling has somewhere
|
||||
// to go even on a sparse seed; the rendered Insights overview
|
||||
// already runs ~1500 px tall on the demo seed.
|
||||
await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" }));
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("main-content");
|
||||
if (el && el.scrollHeight > el.clientHeight) {
|
||||
el.scrollTop = 600;
|
||||
} else {
|
||||
window.scrollTo({ top: 600, behavior: "auto" });
|
||||
}
|
||||
});
|
||||
|
||||
// Click the first available sub-page pill.
|
||||
const subPagePill = page
|
||||
@@ -45,11 +66,10 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
|
||||
await page.waitForURL(/\/insights\/?$/);
|
||||
|
||||
// The mother page's mount-effect deferred a `scrollTo(0)` to the
|
||||
// next animation frame. Wait a tick for it to land.
|
||||
await page.waitForTimeout(50);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeLessThan(50);
|
||||
// The shell's route-change effect resets the scroll on the next tick;
|
||||
// `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it
|
||||
// to settle, then assert the container is back at the top.
|
||||
await expect.poll(readScrollTop, { timeout: 2000 }).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({
|
||||
|
||||
+190
-70
@@ -4018,16 +4018,46 @@
|
||||
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche"
|
||||
"sectionsNav": "Einstellungs-Bereiche",
|
||||
"subSectionsNav": "Einstellungs-Unterbereiche"
|
||||
},
|
||||
"groups": {
|
||||
"account": {
|
||||
"title": "Konto"
|
||||
},
|
||||
"tracking": {
|
||||
"title": "Tracking"
|
||||
},
|
||||
"display": {
|
||||
"title": "Darstellung"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrationen"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen"
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI & Coach"
|
||||
},
|
||||
"access": {
|
||||
"title": "API & Zugriff"
|
||||
},
|
||||
"data": {
|
||||
"title": "Daten & Datenschutz"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"title": "Profil & Einstellungen",
|
||||
"description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.",
|
||||
"subtitle": "Dein Profil, deine Identität und deine Anmeldedaten."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrationen",
|
||||
"title": "Verbindungen",
|
||||
"description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
|
||||
"subtitle": "Datenquellen und Geräte verbinden und verwalten."
|
||||
},
|
||||
@@ -4036,11 +4066,10 @@
|
||||
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.",
|
||||
"channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
|
||||
"channelsHintLink": "Integrationen → Kanäle",
|
||||
"inboxLink": "Posteingang öffnen",
|
||||
"subtitle": "Wähle, welche Erinnerungen du erhältst."
|
||||
},
|
||||
"layout": {
|
||||
"title": "Dashboard",
|
||||
"title": "Darstellung",
|
||||
"description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.",
|
||||
"backToHub": "Zurück zu Layout & Personalisierung",
|
||||
"dashboard": {
|
||||
@@ -4319,9 +4348,9 @@
|
||||
"subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Daten & Datenschutz",
|
||||
"subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.",
|
||||
"description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität."
|
||||
"title": "Datenschutz & Aufbewahrung",
|
||||
"subtitle": "Deine Daten, Aufbewahrung und Verschlüsselung an einem Ort.",
|
||||
"description": "Export, Löschung, Aufbewahrung und Verschlüsselungsstatus."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP-Connector",
|
||||
@@ -4665,7 +4694,9 @@
|
||||
"focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung …",
|
||||
"completeness": "{answered} von {total} beantwortet",
|
||||
"questionsTitle": "Der Coach hat Rückfragen",
|
||||
"questionsOpenCoach": "Im Coach beantworten"
|
||||
"questionsOpenCoach": "Im Coach beantworten",
|
||||
"recordsLink": "Allergien, Familienanamnese und deine Vorerkrankungen gehören zu deiner Anamnese.",
|
||||
"recordsLinkCta": "Anamnese öffnen"
|
||||
},
|
||||
"responseTimeoutHeading": "Antwort-Timeout",
|
||||
"responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.",
|
||||
@@ -5286,6 +5317,11 @@
|
||||
"description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.",
|
||||
"openAi": "KI-Datenschutzmodus",
|
||||
"openResearch": "Forschungsmodus"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Anmeldung & Geräte",
|
||||
"description": "Aktive Sitzungen, vertraute Geräte und Anmeldeaktivität findest du jetzt in den Sicherheitseinstellungen deines Kontos.",
|
||||
"openSecurity": "Anmeldung & Sicherheit verwalten"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -7061,9 +7097,14 @@
|
||||
"restoredToast": "Wert wiederhergestellt",
|
||||
"restoreError": "Wert konnte nicht wiederhergestellt werden",
|
||||
"detail": {
|
||||
"history": "Verlauf",
|
||||
"emptyTitle": "Noch keine Werte",
|
||||
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen."
|
||||
"emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen.",
|
||||
"title": "Biomarker",
|
||||
"genericDescription": "Ein Laborwert, den du verfolgst. Erfasse Messwerte, um den Verlauf gegenüber dem Referenzbereich zu sehen.",
|
||||
"valuesTitle": "{marker} — alle Werte",
|
||||
"valuesDescription": "Jeder Messwert für diesen Biomarker. Bearbeite oder entferne einen Eintrag direkt.",
|
||||
"backToMarker": "Zurück zu {marker}",
|
||||
"latestResult": "Letztes Ergebnis"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Keine Werte in diesem Zeitraum.",
|
||||
@@ -7172,36 +7213,36 @@
|
||||
"bloodCount": "Blutbild"
|
||||
},
|
||||
"desc": {
|
||||
"total-cholesterol": "Gesamtmenge des im Blut transportierten Cholesterins, Summe aus LDL-, HDL- und weiteren Lipoproteinfraktionen.",
|
||||
"ldl": "Cholesterin in Lipoproteinen geringer Dichte, der Anteil, der sich in den Arterienwänden ablagert.",
|
||||
"hdl": "Cholesterin in Lipoproteinen hoher Dichte, die Cholesterin zurück zur Leber transportieren.",
|
||||
"triglycerides": "Das wichtigste Blutfett, aus der Nahrung aufgenommen und in der Leber gebildet.",
|
||||
"fasting-glucose": "Nüchtern gemessener Blutzucker, der die grundlegende Glukoseregulation widerspiegelt.",
|
||||
"hba1c": "Anteil des an Glukose gebundenen Hämoglobins, Spiegel des Blutzuckers der letzten zwei bis drei Monate.",
|
||||
"tsh": "Hypophysenhormon, das die Schilddrüse steuert; der wichtigste Suchtest der Schilddrüsenfunktion.",
|
||||
"ft3": "Die freie, aktive Form des Trijodthyronins, des stärker wirksamen Schilddrüsenhormons.",
|
||||
"ft4": "Die freie Form des Thyroxins, des Haupthormons, das die Schilddrüse ins Blut abgibt.",
|
||||
"ferritin": "Das wichtigste Eisenspeicherprotein des Körpers, Maß für die Eisenreserven.",
|
||||
"transferrin-saturation": "Anteil des Eisentransportproteins Transferrin, der aktuell Eisen trägt.",
|
||||
"vitamin-d": "Zirkulierendes 25-Hydroxy-Vitamin-D, der Standardmarker für den Vitamin-D-Status.",
|
||||
"vitamin-b12": "Vitamin, das für die Bildung roter Blutkörperchen und die Nervenfunktion notwendig ist.",
|
||||
"folate": "B-Vitamin, das für die Bildung roter Blutkörperchen und die DNA-Synthese benötigt wird.",
|
||||
"hs-crp": "Ein Leberprotein, das bei Entzündungen ansteigt, hochempfindlich gemessen.",
|
||||
"creatinine": "Abbauprodukt der Muskulatur, das über die Nieren ausgeschieden wird; Maß der Nierenfunktion.",
|
||||
"egfr": "Geschätzte Filtrationsrate der Nieren, aus dem Kreatinin berechnet.",
|
||||
"alt": "Leberenzym, das bei Schädigung der Leberzellen ins Blut freigesetzt wird.",
|
||||
"ast": "Enzym aus Leber und Muskulatur, das bei Schädigung dieser Gewebe ansteigt.",
|
||||
"ggt": "Enzym aus Leber und Gallenwegen, das bei Gallenstau oder Alkoholbelastung ansteigt.",
|
||||
"sodium": "Wichtigster Elektrolyt für den Flüssigkeitshaushalt und das Blutvolumen.",
|
||||
"potassium": "Elektrolyt, der für die Nervenleitung und die Herzmuskelfunktion unentbehrlich ist.",
|
||||
"hemoglobin": "Das sauerstofftransportierende Protein in den roten Blutkörperchen.",
|
||||
"hematocrit": "Der Anteil der roten Blutkörperchen am Blutvolumen.",
|
||||
"wbc": "Gesamtzahl der weißen Blutkörperchen, der zirkulierenden Abwehr des Immunsystems.",
|
||||
"platelets": "Zellfragmente, die sich zusammenlagern und die Blutgerinnung bewirken.",
|
||||
"apob": "Zählt atherogene Partikel direkt; oft ein besserer Risikomarker als LDL-C, wenn beide auseinandergehen.",
|
||||
"lp-a": "Ein weitgehend vererbter, lebenslanger Risikofaktor — meist einmal gemessen. In molaren Einheiten (nmol/L) angeben.",
|
||||
"fasting-insulin": "Ergänzt den Nüchtern-Blutzucker zur Abschätzung der Insulinresistenz (HOMA-IR).",
|
||||
"omega-3-index": "Anteil von EPA+DHA in den roten Blutkörperchen; 8–12 % gelten als kardioprotektiver Bereich."
|
||||
"total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe – deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.",
|
||||
"ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin – die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.",
|
||||
"hdl": "Das HDL-Cholesterin ist das von High-Density-Lipoprotein-Partikeln transportierte Cholesterin, die überschüssiges Cholesterin aus den Geweben aufnehmen und zur Wiederverwertung oder Ausscheidung zur Leber zurückführen. Wegen dieser Transportfunktion wird es oft als die schützende Fraktion bezeichnet. Sehr niedrige Werte gehen tendenziell mit höherem kardiovaskulärem Risiko einher; ein künstliches Anheben hat das Risiko jedoch nicht zuverlässig gesenkt, weshalb man es als Marker und nicht als Zielgröße liest. In der Regel wird es zusammen mit LDL und Triglyzeriden beurteilt.",
|
||||
"triglycerides": "Triglyzeride sind die wichtigste Form von Fett im Blut, die sowohl aus der Nahrung stammt als auch von der Leber gebildet wird, und sie dienen als zentraler Energiespeicher. Nach Mahlzeiten steigen die Werte, daher wird die Probe für die Vergleichbarkeit meist nüchtern entnommen. Dauerhaft hohe Triglyzeride werden mit metabolischem und kardiovaskulärem Risiko in Verbindung gebracht und treten häufig gemeinsam mit Insulinresistenz und niedrigem HDL auf. Sie werden als ein Bestandteil des gesamten Lipidbilds gelesen.",
|
||||
"fasting-glucose": "Der Nüchternblutzucker ist die Zuckerkonzentration im Blut nach einer nächtlichen Nüchternphase und spiegelt wider, wie der Körper seinen Grundblutzucker ohne kürzliche Nahrungsaufnahme hält. Glukose ist der wichtigste unmittelbare Brennstoff des Körpers, und Insulin hält sie normalerweise in einem engen Bereich. Eine Nüchternmessung schließt den Effekt kürzlicher Mahlzeiten aus und ist damit ein Standardscreening dafür, wie die Glukoseregulation funktioniert. Er gehört zu den Markern, mit denen die Stoffwechselgesundheit über die Zeit beurteilt wird.",
|
||||
"hba1c": "Der HbA1c ist der Anteil des Hämoglobins in den roten Blutkörperchen, der an Glukose gebunden ist und sich im Verhältnis zur durchschnittlichen Blutzuckerbelastung anreichert. Da rote Blutkörperchen etwa drei Monate leben, gibt er den durchschnittlichen Blutzucker der vorangegangenen zwei bis drei Monate wieder und nicht einen einzelnen Augenblick. Das macht ihn zu einem stabilen, mahlzeitenunabhängigen Maß der Blutzuckereinstellung. Seine Aussagekraft kann durch Erkrankungen beeinträchtigt sein, die die Lebensdauer der roten Blutkörperchen verändern.",
|
||||
"tsh": "Das TSH (Thyreoidea-stimulierendes Hormon) wird von der Hirnanhangdrüse ausgeschüttet, um die Schilddrüse zur Hormonproduktion anzuregen, und es passt sich nach oben oder unten an, um diese im Gleichgewicht zu halten. Aufgrund dieses Regelkreises ist das TSH das empfindlichste Erstlinien-Screening der Schilddrüsenfunktion: Es bewegt sich meist, bevor die Schilddrüsenhormone selbst abweichen. Ein erhöhter oder erniedrigter Wert deutet auf eine Unter- oder Überfunktion hin und führt in der Regel zur direkten Bestimmung der Schilddrüsenhormone. Schwangerschaft und Alter verschieben den erwarteten Bereich.",
|
||||
"ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons – der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.",
|
||||
"ft4": "Das freie T4 (Thyroxin) ist die ungebundene Form des wichtigsten Hormons, das die Schilddrüse ins Blut abgibt; es dient als Reservoir, das die Gewebe bei Bedarf in das aktivere T3 umwandeln. Der freie Anteil spiegelt wider, was dem Körper tatsächlich zur Verfügung steht, unabhängig von der Menge der Trägerproteine. In der Regel wird es zusammen mit dem TSH bestimmt, um Probleme der Schilddrüse von Störungen der hypophysären Steuerung zu unterscheiden. Schwangerschaft und bestimmte Medikamente können es verschieben.",
|
||||
"ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen – oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.",
|
||||
"transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins – des Proteins, das Eisen durch das Blut transportiert –, der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.",
|
||||
"vitamin-d": "Vitamin D wird als 25-Hydroxy-Vitamin-D gemessen, der zirkulierenden Speicherform und dem Standardmarker des gesamten Vitamin-D-Status. Der Körper bildet es in der dem Sonnenlicht ausgesetzten Haut und nimmt einen Teil über die Nahrung auf; es unterstützt den Kalziumhaushalt, die Knochengesundheit und die Immunfunktion. Die Werte schwanken mit Jahreszeit, geografischer Breite, Hauttyp und Nahrungsergänzung. Erfasst wird es, um über die Zeit einzuschätzen, ob die Speicher ausreichend gefüllt sind.",
|
||||
"vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel – durch geringe Zufuhr oder gestörte Aufnahme – kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.",
|
||||
"folate": "Folat (Vitamin B9) ist ein B-Vitamin, das in grünem Blattgemüse, Hülsenfrüchten und angereicherten Lebensmitteln vorkommt und für die Bildung roter Blutkörperchen und die DNA-Synthese zentral ist. Es arbeitet eng mit Vitamin B12 zusammen, und ein Mangel an einem von beiden kann eine ähnliche Form der Blutarmut hervorrufen. Eine ausreichende Folatversorgung ist besonders rund um die frühe Schwangerschaft für die gesunde Entwicklung des Kindes wichtig. Gemessen wird es zur Beurteilung des Ernährungsstatus und der Ursache bestimmter Anämien.",
|
||||
"hs-crp": "Das hochsensitive CRP misst das C-reaktive Protein, einen Marker, den die Leber als Reaktion auf Entzündungen ausschüttet, mit einem Verfahren, das auf die für das kardiovaskuläre Risiko relevanten niedrigen Konzentrationen abgestimmt ist. Anders als das Standard-CRP für akute Infektionen erfasst die hochsensitive Variante die feine, chronische Entzündung, die mit Gefäßerkrankungen verbunden ist. Da jede kürzliche Infektion oder Verletzung den Wert anhebt, wird er außerhalb akuter Erkrankung beurteilt und oft mit einer Wiederholungsprobe bestätigt. Er wird als ein Beitrag zum gesamten kardiovaskulären Risiko gelesen.",
|
||||
"creatinine": "Kreatinin ist ein Abfallprodukt des normalen Muskelstoffwechsels, das die Nieren mit recht gleichbleibender Rate aus dem Blut filtern. Da die Produktion verhältnismäßig konstant ist, dient der Blutwert als praktisches Maß dafür, wie gut die Nieren Abfallstoffe ausscheiden. Die Werte werden auch von Muskelmasse, Flüssigkeitshaushalt und Ernährung beeinflusst, weshalb sie meist gemeinsam mit der geschätzten Filtrationsrate beurteilt werden. Es ist ein zentraler Marker der Nierenfunktion.",
|
||||
"egfr": "Die eGFR (geschätzte glomeruläre Filtrationsrate) schätzt, wie viel Blut die Nieren pro Minute filtern, berechnet aus dem Kreatinin zusammen mit Alter und Geschlecht. Sie übersetzt einen rohen Kreatininwert in ein unmittelbarer verständliches Maß der Nierenfunktion. Eine über die Zeit sinkende eGFR ist der wichtigste Weg, um chronische Nierenveränderungen einzustufen und zu verfolgen. Als Schätzwert wird sie als Trend und nicht als einzelner exakter Zahlenwert gelesen.",
|
||||
"alt": "Die ALT (Alanin-Aminotransferase) ist ein vorwiegend in den Leberzellen konzentriertes Enzym, das ins Blut freigesetzt wird, wenn diese Zellen belastet oder geschädigt sind. Da sie verhältnismäßig leberspezifisch ist, gehört sie zu den wichtigsten Markern, um eine Leberschädigung oder -entzündung zu erkennen. Die Werte können bei Fettleber, Virushepatitis, Alkohol oder bestimmten Medikamenten steigen. In der Regel wird sie zusammen mit der AST und den übrigen Markern des Leberpanels gelesen.",
|
||||
"ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt – ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.",
|
||||
"ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist – etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.",
|
||||
"sodium": "Natrium ist der wichtigste Elektrolyt in der Flüssigkeit außerhalb der Körperzellen und der zentrale Regler von Flüssigkeitshaushalt und Blutvolumen. Der Körper steuert es eng über Durst, Hormone und die Nieren, sodass der Blutwert eher das Gleichgewicht zwischen Wasser und Salz widerspiegelt als die Zufuhr allein. Abweichende Werte signalisieren meist eine Störung des Wasserhaushalts und nicht des Natriums selbst. Es ist ein Kernbestandteil des grundlegenden Stoffwechsel- und Elektrolytpanels.",
|
||||
"potassium": "Kalium ist der wichtigste Elektrolyt innerhalb der Körperzellen und für die Nervenleitung und den gleichmäßigen Rhythmus des Herzmuskels unverzichtbar. Sein Blutwert wird durch die Nieren und hormonelle Signale in einem engen Bereich gehalten, da sowohl hohe als auch niedrige Werte die elektrische Aktivität des Herzens stören können. Die Werte können durch Nierenfunktion, Flüssigkeitshaushalt und mehrere gängige Medikamente beeinflusst werden. Es ist ein Kernmaß des Elektrolytpanels.",
|
||||
"hemoglobin": "Hämoglobin ist das eisenhaltige Protein in den roten Blutkörperchen, das Sauerstoff in der Lunge bindet und zu den Geweben transportiert. Seine Konzentration ist das wichtigste Maß für die Sauerstofftransportkapazität des Blutes und der zentrale Marker zum Erkennen einer Blutarmut. Niedrige Werte können auf Blutverlust, Eisen- oder Vitaminmangel oder eine verringerte Bildung hinweisen, hohe Werte können auf Flüssigkeitsmangel oder sauerstoffarme Bedingungen folgen. Es ist ein Eckpfeiler des großen Blutbilds.",
|
||||
"hematocrit": "Der Hämatokrit ist der Anteil der roten Blutkörperchen am Blutvolumen, ausgedrückt als Prozentwert. Er bewegt sich eng mit dem Hämoglobin und gibt einen ergänzenden Blick auf die Erythrozytenmasse und die Sauerstofftransportkapazität des Blutes. Der Flüssigkeitshaushalt beeinflusst ihn, da Veränderungen des Plasmavolumens den Anteil verschieben, selbst wenn die Zahl der roten Blutkörperchen stabil bleibt. Er wird zusammen mit dem Hämoglobin und dem übrigen Blutbild gelesen.",
|
||||
"wbc": "Die Leukozytenzahl ist die Gesamtzahl der im Blut zirkulierenden Immunzellen, der mobilen Abwehr des Körpers gegen Infektionen. Sie steigt als Reaktion auf Infektion, Entzündung oder Stress und kann fallen, wenn das Knochenmark unterdrückt ist oder bei manchen Immunerkrankungen. Da sie mehrere unterschiedliche Zelltypen zusammenfasst, wird eine deutliche Veränderung oft durch ein Differenzialblutbild aufgeschlüsselt. Sie ist ein Kernbestandteil des großen Blutbilds.",
|
||||
"platelets": "Thrombozyten sind kleine Zellfragmente im Blut, die sich zusammenlagern und den ersten Pfropf bilden, der eine Blutung aus einem geschädigten Gefäß stoppt. Ihre Zahl spiegelt das Gleichgewicht zwischen Bildung im Knochenmark und Verbrauch oder Abbau wider. Eine niedrige Zahl kann die Blutungsneigung erhöhen, eine hohe Zahl kann auf eine Entzündung oder andere Reize folgen. Sie sind Teil des großen Blutbilds und ein grundlegendes Maß der Gerinnungsfähigkeit.",
|
||||
"apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht – besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.",
|
||||
"lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.",
|
||||
"fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins – des Hormons, das Glukose aus dem Blut in die Zellen schleust –, gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.",
|
||||
"omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA – den beiden langkettigen marinen Omega-3-Fettsäuren – an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
@@ -7318,8 +7359,8 @@
|
||||
"description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Eingehende Dokumente",
|
||||
"description": "Lade einen Arztbericht oder Entlassbrief hoch; prüfe die erkannten Angaben, bevor sie gespeichert werden. Standardmäßig aus."
|
||||
"label": "Dokumente",
|
||||
"description": "Speichere deine medizinischen Dokumente; optional ihre Angaben auslesen und prüfen, bevor sie gespeichert werden. Standardmäßig aus."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Seelisches Wohlbefinden",
|
||||
@@ -7558,8 +7599,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Check-in zum seelischen Wohlbefinden",
|
||||
"pageDescription": "Zwei validierte, freiwillige Selbsttests — PHQ-9 (Stimmung) und GAD-7 (Sorgen) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
|
||||
"disclaimer": "Dies ist ein Screening-Selbsttest, keine Diagnose. Ein hoher oder niedriger Wert bedeutet nicht, dass eine Erkrankung vorliegt oder nicht. Nur eine qualifizierte Fachperson kann eine Diagnose stellen. Bei Sorgen sprich bitte mit deiner Ärztin oder deinem Arzt.",
|
||||
"optInNote": "Freiwillig und privat: Deine einzelnen Antworten sind verschlüsselt, bleiben aus dem KI-Coach und aus Exporten heraus und werden nie geteilt.",
|
||||
"infoTooltip": "Dieses ist ein Screening-Selbsttest, keine Diagnose.",
|
||||
"attributionLabel": "Quelle",
|
||||
"choosePrompt": "Check-in auswählen",
|
||||
"start": "Starten",
|
||||
@@ -7774,6 +7814,15 @@
|
||||
"updatedToast": "Eintrag aktualisiert",
|
||||
"saveError": "Der Eintrag konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Vorerkrankungen",
|
||||
"cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll — aus deinem persönlichen Kontext.",
|
||||
"conditionsLabel": "Chronische Erkrankungen",
|
||||
"focusLabel": "Worauf der Coach achtet",
|
||||
"empty": "Noch nichts hinterlegt.",
|
||||
"editCta": "Im persönlichen Kontext bearbeiten",
|
||||
"loadError": "Vorerkrankungen konnten nicht geladen werden."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -7788,37 +7837,50 @@
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Eingehende Dokumente",
|
||||
"subtitle": "Lade ein klinisches Dokument hoch und prüfe die Angaben, die wir daraus gelesen haben.",
|
||||
"disclaimer": {
|
||||
"title": "Wir lesen, wir deuten nicht",
|
||||
"body": "Wir übernehmen nur das, was dein Dokument angibt — Werte, Codes und Daten — und stellen keine Diagnose, deuten nichts und bewerten nichts. Nichts wird gespeichert, bevor du es geprüft und bestätigt hast. Nur deine Ärztin oder dein Arzt kann diese Ergebnisse beurteilen."
|
||||
},
|
||||
"title": "Dokumente",
|
||||
"subtitle": "Speichere deine medizinischen Dokumente und prüfe, was darin steht.",
|
||||
"upload": {
|
||||
"label": "Dokument hochladen",
|
||||
"kindLabel": "Dokumenttyp",
|
||||
"label": "Dokument hinzufügen",
|
||||
"kindLabel": "Kategorie",
|
||||
"button": "Hochladen",
|
||||
"uploading": "Wird gelesen …",
|
||||
"fileLabel": "Dokumentdatei"
|
||||
"uploading": "Wird hochgeladen…",
|
||||
"fileLabel": "Datei (erforderlich)",
|
||||
"titleLabel": "Titel (optional)",
|
||||
"titlePlaceholder": "z. B. Kardiologie-Bericht",
|
||||
"dateLabel": "Dokumentdatum (optional)",
|
||||
"fileRequired": "Wähle eine Datei zum Hochladen.",
|
||||
"accepts": "JPEG, PNG, WebP oder PDF · bis 12 MB"
|
||||
},
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Arztbericht",
|
||||
"DISCHARGE_LETTER": "Entlassbrief",
|
||||
"OTHER": "Sonstiges"
|
||||
"DISCHARGE_LETTER": "Entlassungsbrief",
|
||||
"OTHER": "Sonstiges",
|
||||
"LAB_RESULT": "Laborbefund",
|
||||
"IMAGING": "Bildgebung",
|
||||
"PRESCRIPTION": "Rezept",
|
||||
"INSURANCE": "Versicherung",
|
||||
"VACCINATION": "Impfung"
|
||||
},
|
||||
"list": {
|
||||
"empty": "Noch keine Dokumente."
|
||||
"empty": "Noch keine Dokumente.",
|
||||
"noResults": "Keine Dokumente passen zu deinen Filtern.",
|
||||
"loadMore": "Mehr laden",
|
||||
"loadingMore": "Wird geladen…",
|
||||
"pending": "{count} zu prüfen",
|
||||
"facts": "{count} Angaben",
|
||||
"clearFilters": "Filter zurücksetzen",
|
||||
"loadError": "Deine Dokumente konnten nicht geladen werden."
|
||||
},
|
||||
"status": {
|
||||
"EXTRACTING": "Wird gelesen",
|
||||
"EXTRACTED": "Bereit zur Prüfung",
|
||||
"FAILED": "Konnte nicht gelesen werden",
|
||||
"CONFIRMED": "Geprüft",
|
||||
"DISCARDED": "Verworfen"
|
||||
"DISCARDED": "Verworfen",
|
||||
"STORED": "Gespeichert"
|
||||
},
|
||||
"review": {
|
||||
"title": "Erkannte Angaben prüfen",
|
||||
"discard": "Verwerfen",
|
||||
"confirm": "Bestätigte Angaben speichern",
|
||||
"confirming": "Wird gespeichert …",
|
||||
"needsReview": "Prüfung nötig",
|
||||
@@ -7826,7 +7888,14 @@
|
||||
"reject": "Ablehnen",
|
||||
"edit": "Bearbeiten",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern"
|
||||
"save": "Speichern",
|
||||
"needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.",
|
||||
"confidenceLabel": "Erkennungssicherheit",
|
||||
"commitError": {
|
||||
"unitMismatch": "die Einheit stimmt nicht mit der gespeicherten überein",
|
||||
"unitRequired": "ein Zahlenwert braucht eine Einheit",
|
||||
"generic": "es konnte nicht gespeichert werden"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noFacts": "Nichts mehr zu prüfen."
|
||||
@@ -7844,21 +7913,72 @@
|
||||
"dose": "Dosis"
|
||||
},
|
||||
"toast": {
|
||||
"uploaded": "Dokument gelesen. Prüfe die Angaben unten.",
|
||||
"uploadFailed": "Dokument konnte nicht gelesen werden.",
|
||||
"uploaded": "Dokument gespeichert.",
|
||||
"uploadFailed": "Dokument konnte nicht gespeichert werden.",
|
||||
"confirmed": "Bestätigte Angaben gespeichert.",
|
||||
"confirmedPartial": "Gespeichert, aber {count} konnten nicht gespeichert werden — {reason}.",
|
||||
"confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.",
|
||||
"saved": "Angabe aktualisiert.",
|
||||
"discarded": "Dokument verworfen."
|
||||
"discarded": "Dokument verworfen.",
|
||||
"extracted": "Angaben ausgelesen. Prüfe sie unten.",
|
||||
"extractFailed": "Angaben konnten nicht ausgelesen werden.",
|
||||
"localOcrDisabled": "Aktiviere lokale OCR in den Einstellungen, um dieses Dokument auf dem Gerät zu lesen.",
|
||||
"updated": "Dokument aktualisiert.",
|
||||
"updateFailed": "Dokument konnte nicht aktualisiert werden.",
|
||||
"discardFailed": "Dokument konnte nicht verworfen werden.",
|
||||
"uploadTooLarge": "Datei ist zu groß (max. 12 MB).",
|
||||
"uploadFileType": "Lade eine JPEG-, PNG-, WebP- oder PDF-Datei hoch.",
|
||||
"uploadRateLimited": "Zu viele Uploads. Versuch es später erneut.",
|
||||
"uploadInvalidMetadata": "Prüfe Titel, Kategorie oder Datum und versuch es erneut."
|
||||
},
|
||||
"toolbar": {
|
||||
"searchLabel": "Suche",
|
||||
"searchPlaceholder": "Titel oder Dateiname",
|
||||
"kindFilterLabel": "Kategorie",
|
||||
"allKinds": "Alle Kategorien",
|
||||
"sortLabel": "Sortieren nach",
|
||||
"sort": {
|
||||
"documentDate": "Dokumentdatum",
|
||||
"createdAt": "Hinzugefügt am",
|
||||
"title": "Titel"
|
||||
},
|
||||
"orderAsc": "Aufsteigend",
|
||||
"orderDesc": "Absteigend",
|
||||
"fromLabel": "Von",
|
||||
"toLabel": "Bis"
|
||||
},
|
||||
"detail": {
|
||||
"viewOriginal": "Original ansehen",
|
||||
"edit": "Bearbeiten",
|
||||
"extract": "Angaben auslesen",
|
||||
"extracting": "Wird ausgelesen…",
|
||||
"discard": "Verwerfen",
|
||||
"notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.",
|
||||
"viewOriginalNewTab": "(öffnet in einem neuen Tab)",
|
||||
"discardConfirmTitle": "Dieses Dokument verwerfen?",
|
||||
"discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Angaben dauerhaft. Das lässt sich nicht rückgängig machen.",
|
||||
"discardConfirmAction": "Verwerfen",
|
||||
"discardCancel": "Abbrechen"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Dokument bearbeiten",
|
||||
"subtitle": "Umbenennen, neu kategorisieren oder das Dokumentdatum setzen.",
|
||||
"titleLabel": "Titel",
|
||||
"kindLabel": "Kategorie",
|
||||
"dateLabel": "Dokumentdatum",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert…",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"note": {
|
||||
"providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Angaben automatisch auszulesen.",
|
||||
"alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden."
|
||||
},
|
||||
"committed": {
|
||||
"title": "Aus diesem Dokument übernommen"
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog ist ein persönliches Werkzeug zur Gesundheitsdokumentation und zum Wohlbefinden. Es hilft dir, deine eigenen Gesundheitsdaten zu erfassen, zu visualisieren und zu exportieren. Es diagnostiziert, behandelt, heilt oder verhindert keine Krankheit und ist kein Medizinprodukt.",
|
||||
"coach": "Der Coach arbeitet mit deinen eigenen erfassten Daten. Er ist kein Arzt und gibt keine medizinische Beratung — für Diagnose oder Behandlung wende dich an medizinisches Fachpersonal.",
|
||||
"insights": "Das sind beschreibende Muster aus deinen eigenen erfassten Daten, keine klinische Beurteilung und keine Diagnose.",
|
||||
"labs": "Das sind deine eigenen Aufzeichnungen, keine medizinische Diagnose. Referenzbereiche unterscheiden sich je nach Labor und Person — nur deine Ärztin oder dein Arzt kann sie für dich einordnen.",
|
||||
"medications": "Das erfasst die von dir dokumentierten Medikamente und deine eigene Einnahmetreue. Es beurteilt weder Dosierung noch Wechselwirkungen und ist keine medizinische Beratung — kläre das mit Apotheke oder Arztpraxis.",
|
||||
"emergency": "Falls das ein Notfall sein könnte, wende dich jetzt an deine örtliche Notrufnummer oder an eine ärztliche Stelle. HealthLog kann im Notfall nicht helfen.",
|
||||
"dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten."
|
||||
}
|
||||
}
|
||||
+191
-71
@@ -4018,16 +4018,46 @@
|
||||
"disableError": "Could not disable Research Mode. Please try again."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
"sectionsNav": "Settings sections",
|
||||
"subSectionsNav": "Settings sub-sections"
|
||||
},
|
||||
"groups": {
|
||||
"account": {
|
||||
"title": "Account"
|
||||
},
|
||||
"tracking": {
|
||||
"title": "Tracking"
|
||||
},
|
||||
"display": {
|
||||
"title": "Display"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications"
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI & Coach"
|
||||
},
|
||||
"access": {
|
||||
"title": "API & Access"
|
||||
},
|
||||
"data": {
|
||||
"title": "Data & Privacy"
|
||||
},
|
||||
"about": {
|
||||
"title": "About"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"title": "Profile & preferences",
|
||||
"description": "Profile, password, passkeys, and your onboarding tour.",
|
||||
"subtitle": "Your profile, identity, and sign-in details."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"title": "Connections",
|
||||
"description": "Connected services, delivery channels, and source priority in one place.",
|
||||
"subtitle": "Connect and manage your data sources and devices."
|
||||
},
|
||||
@@ -4036,11 +4066,10 @@
|
||||
"description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
|
||||
"channelsHintLink": "Integrations → Channels",
|
||||
"inboxLink": "Open the inbox",
|
||||
"subtitle": "Choose which reminders you receive."
|
||||
},
|
||||
"layout": {
|
||||
"title": "Dashboard",
|
||||
"title": "Display",
|
||||
"description": "Arrange how the dashboard and insights are laid out.",
|
||||
"backToHub": "Back to Layout & Personalization",
|
||||
"dashboard": {
|
||||
@@ -4319,9 +4348,9 @@
|
||||
"subtitle": "Two-factor authentication, recovery codes, and passkeys."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Data & Privacy",
|
||||
"subtitle": "Your data, retention, encryption, and account security in one place.",
|
||||
"description": "Export, deletion, retention, encryption status, sessions, and security activity."
|
||||
"title": "Privacy & retention",
|
||||
"subtitle": "Your data, retention, and encryption in one place.",
|
||||
"description": "Export, deletion, retention, and encryption status."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Connector",
|
||||
@@ -4665,7 +4694,9 @@
|
||||
"focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…",
|
||||
"completeness": "{answered} of {total} answered",
|
||||
"questionsTitle": "The Coach has follow-up questions",
|
||||
"questionsOpenCoach": "Answer them in the Coach"
|
||||
"questionsOpenCoach": "Answer them in the Coach",
|
||||
"recordsLink": "Allergies, family history and your pre-existing conditions are part of your medical history.",
|
||||
"recordsLinkCta": "Open medical history"
|
||||
},
|
||||
"responseTimeoutHeading": "Response timeout",
|
||||
"responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.",
|
||||
@@ -5286,6 +5317,11 @@
|
||||
"description": "Control how much your data is shared with AI features and opt-in research.",
|
||||
"openAi": "AI privacy mode",
|
||||
"openResearch": "Research mode"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Sign-in & devices",
|
||||
"description": "Active sessions, trusted devices, and login activity now live in your account security settings.",
|
||||
"openSecurity": "Manage sign-in & security"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -7061,9 +7097,14 @@
|
||||
"restoredToast": "Reading restored",
|
||||
"restoreError": "Could not restore the reading",
|
||||
"detail": {
|
||||
"history": "History",
|
||||
"emptyTitle": "No readings yet",
|
||||
"emptyDescription": "Add a value to start tracking this biomarker over time."
|
||||
"emptyDescription": "Add a value to start tracking this biomarker over time.",
|
||||
"title": "Biomarker",
|
||||
"genericDescription": "A lab biomarker you track. Add readings to follow how it trends against its reference range.",
|
||||
"valuesTitle": "{marker} — all readings",
|
||||
"valuesDescription": "Every reading for this biomarker. Edit or remove an entry directly.",
|
||||
"backToMarker": "Back to {marker}",
|
||||
"latestResult": "Latest result"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "No readings in this period.",
|
||||
@@ -7155,8 +7196,8 @@
|
||||
"ggt": "GGT",
|
||||
"sodium": "Sodium",
|
||||
"potassium": "Potassium",
|
||||
"hemoglobin": "Hemoglobin",
|
||||
"hematocrit": "Hematocrit",
|
||||
"hemoglobin": "Haemoglobin",
|
||||
"hematocrit": "Haematocrit",
|
||||
"wbc": "White blood cells",
|
||||
"platelets": "Platelets",
|
||||
"panel": {
|
||||
@@ -7172,36 +7213,36 @@
|
||||
"bloodCount": "Blood count"
|
||||
},
|
||||
"desc": {
|
||||
"total-cholesterol": "Total amount of cholesterol carried in the blood, summing the LDL, HDL, and other lipoprotein fractions.",
|
||||
"ldl": "Cholesterol carried by low-density lipoproteins, the fraction that deposits in artery walls.",
|
||||
"hdl": "Cholesterol carried by high-density lipoproteins, which transport cholesterol back to the liver.",
|
||||
"triglycerides": "The main fat circulating in the blood, drawn from diet and produced by the liver.",
|
||||
"fasting-glucose": "Blood sugar measured after an overnight fast, reflecting baseline glucose regulation.",
|
||||
"hba1c": "The share of haemoglobin bound to glucose, reflecting average blood sugar over the past two to three months.",
|
||||
"tsh": "Pituitary hormone that signals the thyroid; the primary screen of thyroid function.",
|
||||
"ft3": "The unbound, active form of triiodothyronine, the more potent thyroid hormone.",
|
||||
"ft4": "The unbound form of thyroxine, the main hormone the thyroid releases into the blood.",
|
||||
"ferritin": "The body's main iron-storage protein, reflecting total iron reserves.",
|
||||
"transferrin-saturation": "Share of the iron-transport protein transferrin that is currently carrying iron.",
|
||||
"vitamin-d": "Circulating 25-hydroxyvitamin D, the standard marker of vitamin D status.",
|
||||
"vitamin-b12": "Vitamin essential for red blood cell formation and nerve function.",
|
||||
"folate": "B-vitamin needed for red blood cell formation and DNA synthesis.",
|
||||
"hs-crp": "A liver protein that rises with inflammation, measured at high sensitivity.",
|
||||
"creatinine": "Muscle waste product cleared by the kidneys, used to gauge kidney function.",
|
||||
"egfr": "Estimated rate at which the kidneys filter blood, derived from creatinine.",
|
||||
"alt": "Liver enzyme released into the blood when liver cells are damaged.",
|
||||
"ast": "Enzyme found in liver and muscle that rises when those tissues are injured.",
|
||||
"ggt": "Liver and bile-duct enzyme that rises with bile-flow obstruction or alcohol exposure.",
|
||||
"sodium": "Principal electrolyte governing the body's fluid balance and blood volume.",
|
||||
"potassium": "Electrolyte essential for nerve signalling and heart-muscle function.",
|
||||
"hemoglobin": "The oxygen-carrying protein in red blood cells.",
|
||||
"hematocrit": "The fraction of blood volume made up of red blood cells.",
|
||||
"wbc": "Total count of white blood cells, the immune system's circulating defence.",
|
||||
"platelets": "Cell fragments that clump together to form blood clots.",
|
||||
"apob": "Counts atherogenic particles directly; often a better risk marker than LDL-C when the two disagree.",
|
||||
"lp-a": "A largely inherited, lifelong risk factor — usually measured once. Report molar units (nmol/L).",
|
||||
"fasting-insulin": "Pairs with fasting glucose to estimate insulin resistance (HOMA-IR).",
|
||||
"omega-3-index": "Red-blood-cell EPA+DHA share; 8–12% is the commonly cited cardioprotective range."
|
||||
"total-cholesterol": "Total cholesterol is the sum of the cholesterol carried across all lipoprotein particles in the blood, including the LDL, HDL, and remnant fractions. Cholesterol itself is an essential building block for cell membranes, hormones, and bile acids. As a single number it is a coarse summary, because both a healthy HDL share and a high LDL share lift the total — so it is usually read alongside the individual fractions. It is tracked mainly as part of long-term cardiovascular risk assessment.",
|
||||
"ldl": "LDL cholesterol is the cholesterol carried by low-density lipoprotein particles, the fraction that delivers cholesterol to tissues but can also deposit it in artery walls. Over years this deposition contributes to atherosclerotic plaque, which is why LDL is one of the most closely watched lipid markers. Lower long-term exposure generally tracks with lower cardiovascular risk. It is interpreted by risk category rather than against one fixed normal value.",
|
||||
"hdl": "HDL cholesterol is the cholesterol carried by high-density lipoprotein particles, which collect surplus cholesterol from tissues and return it to the liver for recycling or removal. Because of this transport role it is often described as the protective fraction. Very low levels tend to accompany higher cardiovascular risk, though raising it artificially has not reliably reduced risk, so it is read as a marker rather than a target. It is usually interpreted together with LDL and triglycerides.",
|
||||
"triglycerides": "Triglycerides are the main form of fat circulating in the blood, supplied both by the diet and produced by the liver, and they serve as a key energy store. Levels rise after meals, so the sample is usually taken fasting for comparability. Persistently high triglycerides are associated with metabolic and cardiovascular risk and often travel with insulin resistance and low HDL. They are read as one component of the overall lipid picture.",
|
||||
"fasting-glucose": "Fasting glucose is the concentration of sugar in the blood measured after an overnight fast, reflecting how the body holds its baseline blood-sugar level without recent food. Glucose is the body's primary immediate fuel, and insulin normally keeps it within a narrow range. A fasting measurement removes the effect of recent meals, making it a standard screen for how glucose regulation is functioning. It is one of the markers used to assess metabolic health over time.",
|
||||
"hba1c": "HbA1c is the share of haemoglobin in red blood cells that has become bound to glucose, which accumulates in proportion to average blood-sugar exposure. Because red cells live for about three months, it reflects average glucose over the preceding two to three months rather than a single moment. This makes it a stable, meal-independent view of glucose control. Its accuracy can be affected by conditions that change red-cell lifespan.",
|
||||
"tsh": "TSH (thyroid-stimulating hormone) is released by the pituitary gland to signal the thyroid to produce its hormones, and it adjusts up or down to keep them in balance. Because of this feedback loop, TSH is the most sensitive first-line screen of thyroid function: it tends to move before the thyroid hormones themselves drift. A raised or lowered TSH points toward an under- or over-active thyroid and usually prompts measurement of the thyroid hormones directly. Pregnancy and age shift the expected range.",
|
||||
"ft3": "Free T3 (triiodothyronine) is the unbound, biologically active form of the more potent thyroid hormone, the fraction available to act on tissues. Most circulating T3 is bound to carrier proteins and inactive, so the free portion is what reflects true hormone availability. It is measured to characterise thyroid function, especially alongside TSH and free T4 when results are unclear. Levels are strongly assay-dependent and read in clinical context.",
|
||||
"ft4": "Free T4 (thyroxine) is the unbound form of the main hormone the thyroid releases into the blood, serving as a reservoir that tissues convert to the more active T3 as needed. The free fraction reflects what is actually available to the body, independent of carrier-protein levels. It is usually measured together with TSH to separate thyroid-gland problems from pituitary signalling. Pregnancy and certain medications can shift it.",
|
||||
"ferritin": "Ferritin is the body's main iron-storage protein, and its blood level broadly mirrors total iron reserves. A low value points toward depleted stores, while a high value can reflect iron overload — or simply inflammation, because ferritin also rises as an acute-phase reactant. For that reason it is often interpreted alongside other iron markers and signs of inflammation. It is one of the core measures used to assess iron status.",
|
||||
"transferrin-saturation": "Transferrin saturation is the percentage of transferrin — the protein that ferries iron through the blood — that is currently loaded with iron. It links the amount of iron in transit to the body's iron-carrying capacity, giving a snapshot of iron availability that complements ferritin's view of storage. A low saturation suggests iron is in short supply, while a high value can indicate overload. It is read together with ferritin and the full iron panel.",
|
||||
"vitamin-d": "Vitamin D is measured as 25-hydroxyvitamin D, the circulating storage form and the standard marker of overall vitamin D status. The body makes it in skin exposed to sunlight and absorbs some from food, and it supports calcium balance, bone health, and immune function. Levels vary with season, latitude, skin tone, and supplementation. It is tracked to gauge whether stores are adequate over time.",
|
||||
"vitamin-b12": "Vitamin B12 (cobalamin) is an essential vitamin obtained mainly from animal-source foods and stored largely in the liver. It is required for red-blood-cell formation, DNA synthesis, and the health of the nervous system. Prolonged shortfall — from low intake or impaired absorption — can lead to anaemia and nerve-related symptoms. It is measured to assess nutritional status, particularly where intake or absorption may be limited.",
|
||||
"folate": "Folate (vitamin B9) is a B-vitamin found in leafy greens, legumes, and fortified foods, central to red-blood-cell formation and DNA synthesis. It works closely with vitamin B12, and a shortfall in either can produce a similar type of anaemia. Adequate folate is especially important around early pregnancy for healthy fetal development. It is measured to assess nutritional status and the cause of certain anaemias.",
|
||||
"hs-crp": "High-sensitivity CRP measures C-reactive protein, a marker the liver releases in response to inflammation, using an assay refined to detect the low concentrations relevant to cardiovascular risk. Unlike standard CRP used for acute infection, the high-sensitivity version captures the subtle, chronic inflammation linked to vascular disease. Because any recent infection or injury can raise it, it is interpreted away from acute illness and often confirmed with a repeat sample. It is read as one contributor to overall cardiovascular risk.",
|
||||
"creatinine": "Creatinine is a waste product of normal muscle metabolism that the kidneys filter out of the blood at a fairly steady rate. Because production is relatively constant, its blood level serves as a practical gauge of how well the kidneys are clearing waste. Values are also influenced by muscle mass, hydration, and diet, so it is usually interpreted alongside the estimated filtration rate. It is a core marker of kidney function.",
|
||||
"egfr": "eGFR (estimated glomerular filtration rate) estimates how much blood the kidneys filter each minute, calculated from creatinine together with age and sex. It translates a raw creatinine value into a more directly interpretable measure of kidney function. A declining eGFR over time is the main way chronic kidney changes are staged and tracked. As an estimate it is read as a trend rather than a single exact figure.",
|
||||
"alt": "ALT (alanine aminotransferase) is an enzyme concentrated in liver cells, released into the blood when those cells are stressed or damaged. Because it is relatively liver-specific, it is one of the primary markers used to detect liver injury or inflammation. Levels can rise with fatty liver, viral hepatitis, alcohol, or certain medications. It is usually read together with AST and the other liver-panel markers.",
|
||||
"ast": "AST (aspartate aminotransferase) is an enzyme found in the liver but also in muscle, heart, and red blood cells, released when any of those tissues is injured. Because it is less liver-specific than ALT, the two are interpreted together — their ratio can hint at the source and nature of the problem. Vigorous exercise or muscle injury can raise it independently of the liver. It is one of the standard liver-panel enzymes.",
|
||||
"ggt": "GGT (gamma-glutamyl transferase) is an enzyme found in the liver and the bile ducts that rises particularly when bile flow is obstructed or with alcohol exposure. It is often used to clarify why another liver marker is elevated — for example, to distinguish a liver or bile-duct source. It is sensitive but not very specific, so it is read in context with the rest of the liver panel. It is one of several markers used to assess liver and biliary health.",
|
||||
"sodium": "Sodium is the principal electrolyte in the fluid outside the body's cells and the main driver of fluid balance and blood volume. The body regulates it tightly through thirst, hormones, and the kidneys, so its blood level reflects the balance between water and salt more than dietary intake alone. Abnormal values usually signal a disturbance of water balance rather than of sodium itself. It is a core part of the basic metabolic and electrolyte panel.",
|
||||
"potassium": "Potassium is the main electrolyte inside the body's cells and is essential for nerve signalling and the steady rhythm of the heart muscle. Its blood level is held within a narrow range by the kidneys and hormonal signals, because both high and low values can disturb the heart's electrical activity. Levels can be affected by kidney function, hydration, and several common medications. It is a core measure in the electrolyte panel.",
|
||||
"hemoglobin": "Haemoglobin is the iron-containing protein in red blood cells that binds oxygen in the lungs and carries it to the tissues. Its concentration is the main measure of the blood's oxygen-carrying capacity and the central marker for detecting anaemia. Low values can reflect blood loss, iron or vitamin shortfalls, or reduced production, while high values can follow dehydration or low-oxygen conditions. It is a cornerstone of the complete blood count.",
|
||||
"hematocrit": "Haematocrit is the proportion of blood volume made up of red blood cells, expressed as a percentage. It moves closely with haemoglobin and gives a complementary view of red-cell mass and the blood's oxygen-carrying capacity. Hydration affects it, since plasma-volume changes shift the proportion even when red-cell numbers are stable. It is read alongside haemoglobin and the rest of the blood count.",
|
||||
"wbc": "The white blood cell count is the total number of immune cells circulating in the blood, the body's mobile defence against infection. It rises in response to infection, inflammation, or stress and can fall when the bone marrow is suppressed or in some immune conditions. Because it sums several distinct cell types, a notable change is often followed up with a differential that breaks it down. It is a core component of the complete blood count.",
|
||||
"platelets": "Platelets are small cell fragments in the blood that clump together and form the initial plug that stops bleeding from a damaged vessel. Their count reflects the balance between production in the bone marrow and consumption or breakdown. A low count can raise bleeding tendency, while a high count can follow inflammation or other stimuli. It is part of the complete blood count and a basic gauge of clotting capacity.",
|
||||
"apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses — particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.",
|
||||
"lp-a": "Lipoprotein(a) is an LDL-like particle with an extra attached protein, and its level is largely set by genetics and stays fairly stable through life. Elevated Lp(a) is an independent, inherited risk factor for cardiovascular disease and aortic-valve narrowing. Because it changes little, it is usually measured once to establish a person's lifelong risk rather than tracked repeatedly. It is reported in molar units and read as part of overall cardiovascular risk.",
|
||||
"fasting-insulin": "Fasting insulin is the level of insulin — the hormone that moves glucose from the blood into cells — measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.",
|
||||
"omega-3-index": "The omega-3 index is the share of EPA and DHA — the two long-chain marine omega-3 fatty acids — among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
@@ -7318,8 +7359,8 @@
|
||||
"description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Inbound documents",
|
||||
"description": "Upload a doctor report or discharge letter; review the extracted facts before they're saved. Off by default."
|
||||
"label": "Documents",
|
||||
"description": "Store your clinical documents; optionally extract and review their facts before they're saved. Off by default."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Mental wellbeing",
|
||||
@@ -7558,8 +7599,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Mental wellbeing check-in",
|
||||
"pageDescription": "Two validated, opt-in self-assessments — PHQ-9 (mood) and GAD-7 (worry) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
|
||||
"disclaimer": "This is a screening self-assessment, not a diagnosis. A high or low score does not mean you do or do not have a condition. Only a qualified health professional can diagnose. If you have concerns, please talk to your doctor.",
|
||||
"optInNote": "Opt-in and private: your individual answers are encrypted, kept out of the AI Coach and exports, and never shared.",
|
||||
"infoTooltip": "This is a screening self-test, not a diagnosis.",
|
||||
"attributionLabel": "Attribution",
|
||||
"choosePrompt": "Choose a check-in",
|
||||
"start": "Start",
|
||||
@@ -7774,6 +7814,15 @@
|
||||
"updatedToast": "Entry updated",
|
||||
"saveError": "Couldn't save the entry."
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Pre-existing conditions",
|
||||
"cardDescription": "Chronic conditions and what the Coach watches — from your personal context.",
|
||||
"conditionsLabel": "Chronic conditions",
|
||||
"focusLabel": "What the Coach watches",
|
||||
"empty": "Nothing noted yet.",
|
||||
"editCta": "Edit in personal context",
|
||||
"loadError": "Couldn't load your conditions."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -7788,37 +7837,50 @@
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Inbound documents",
|
||||
"subtitle": "Upload a clinical document and review the facts we read from it.",
|
||||
"disclaimer": {
|
||||
"title": "We read, we don't interpret",
|
||||
"body": "We only transcribe what your document states — values, codes, and dates — and never diagnose, interpret, or flag anything. Nothing is saved until you review and approve it. Only your clinician can interpret these results."
|
||||
},
|
||||
"title": "Documents",
|
||||
"subtitle": "Store your clinical documents and review what they say.",
|
||||
"upload": {
|
||||
"label": "Upload a document",
|
||||
"kindLabel": "Document type",
|
||||
"label": "Add a document",
|
||||
"kindLabel": "Category",
|
||||
"button": "Upload",
|
||||
"uploading": "Reading…",
|
||||
"fileLabel": "Document file"
|
||||
"uploading": "Uploading…",
|
||||
"fileLabel": "File (required)",
|
||||
"titleLabel": "Title (optional)",
|
||||
"titlePlaceholder": "e.g. Cardiology report",
|
||||
"dateLabel": "Document date (optional)",
|
||||
"fileRequired": "Choose a file to upload.",
|
||||
"accepts": "JPEG, PNG, WebP or PDF · up to 12 MB"
|
||||
},
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Doctor report",
|
||||
"DISCHARGE_LETTER": "Discharge letter",
|
||||
"OTHER": "Other"
|
||||
"OTHER": "Other",
|
||||
"LAB_RESULT": "Lab result",
|
||||
"IMAGING": "Imaging",
|
||||
"PRESCRIPTION": "Prescription",
|
||||
"INSURANCE": "Insurance",
|
||||
"VACCINATION": "Vaccination"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No documents yet."
|
||||
"empty": "No documents yet.",
|
||||
"noResults": "No documents match your filters.",
|
||||
"loadMore": "Load more",
|
||||
"loadingMore": "Loading…",
|
||||
"pending": "{count} to review",
|
||||
"facts": "{count} facts",
|
||||
"clearFilters": "Clear filters",
|
||||
"loadError": "Couldn't load your documents."
|
||||
},
|
||||
"status": {
|
||||
"EXTRACTING": "Reading",
|
||||
"EXTRACTED": "Ready to review",
|
||||
"FAILED": "Couldn't read",
|
||||
"CONFIRMED": "Reviewed",
|
||||
"DISCARDED": "Discarded"
|
||||
"DISCARDED": "Discarded",
|
||||
"STORED": "Stored"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review extracted facts",
|
||||
"discard": "Discard",
|
||||
"confirm": "Save approved facts",
|
||||
"confirming": "Saving…",
|
||||
"needsReview": "Needs review",
|
||||
@@ -7826,7 +7888,14 @@
|
||||
"reject": "Reject",
|
||||
"edit": "Edit",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"needsReviewHint": "Edit this fact to approve it.",
|
||||
"confidenceLabel": "Extraction confidence",
|
||||
"commitError": {
|
||||
"unitMismatch": "the unit doesn't match the saved one",
|
||||
"unitRequired": "a numeric value needs a unit",
|
||||
"generic": "it couldn't be saved"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noFacts": "Nothing left to review."
|
||||
@@ -7844,21 +7913,72 @@
|
||||
"dose": "Dose"
|
||||
},
|
||||
"toast": {
|
||||
"uploaded": "Document read. Review the facts below.",
|
||||
"uploadFailed": "Couldn't read the document.",
|
||||
"uploaded": "Document stored.",
|
||||
"uploadFailed": "Couldn't store the document.",
|
||||
"confirmed": "Approved facts saved.",
|
||||
"confirmedPartial": "Saved, but {count} couldn't be saved — {reason}.",
|
||||
"confirmFailed": "Couldn't save your decisions.",
|
||||
"saved": "Fact updated.",
|
||||
"discarded": "Document discarded."
|
||||
"discarded": "Document discarded.",
|
||||
"extracted": "Facts extracted. Review them below.",
|
||||
"extractFailed": "Couldn't extract facts.",
|
||||
"localOcrDisabled": "Enable local OCR in settings to read this document on-device.",
|
||||
"updated": "Document updated.",
|
||||
"updateFailed": "Couldn't update the document.",
|
||||
"discardFailed": "Couldn't discard the document.",
|
||||
"uploadTooLarge": "File is too large (max 12 MB).",
|
||||
"uploadFileType": "Upload a JPEG, PNG, WebP, or PDF.",
|
||||
"uploadRateLimited": "Too many uploads. Try again later.",
|
||||
"uploadInvalidMetadata": "Check the title, category, or date and try again."
|
||||
},
|
||||
"toolbar": {
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Title or filename",
|
||||
"kindFilterLabel": "Category",
|
||||
"allKinds": "All categories",
|
||||
"sortLabel": "Sort by",
|
||||
"sort": {
|
||||
"documentDate": "Document date",
|
||||
"createdAt": "Date added",
|
||||
"title": "Title"
|
||||
},
|
||||
"orderAsc": "Ascending",
|
||||
"orderDesc": "Descending",
|
||||
"fromLabel": "From",
|
||||
"toLabel": "To"
|
||||
},
|
||||
"detail": {
|
||||
"viewOriginal": "View original",
|
||||
"edit": "Edit",
|
||||
"extract": "Extract facts",
|
||||
"extracting": "Extracting…",
|
||||
"discard": "Discard",
|
||||
"notExtracted": "No facts extracted yet. Use Extract facts to read this document.",
|
||||
"viewOriginalNewTab": "(opens in a new tab)",
|
||||
"discardConfirmTitle": "Discard this document?",
|
||||
"discardConfirmBody": "This permanently deletes the document, its original file, and any staged facts. This can't be undone.",
|
||||
"discardConfirmAction": "Discard",
|
||||
"discardCancel": "Cancel"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit document",
|
||||
"subtitle": "Rename, recategorise, or set the document date.",
|
||||
"titleLabel": "Title",
|
||||
"kindLabel": "Category",
|
||||
"dateLabel": "Document date",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"note": {
|
||||
"providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.",
|
||||
"alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again."
|
||||
},
|
||||
"committed": {
|
||||
"title": "Approved from this document"
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog is a personal health record-keeping and wellness tool. It helps you log, visualise, and export your own health data. It does not diagnose, treat, cure, or prevent any disease, and it is not a medical device.",
|
||||
"coach": "The Coach works from your own logged data. It isn't a doctor and doesn't give medical advice — for diagnosis or treatment, talk to a healthcare professional.",
|
||||
"insights": "These are descriptive patterns computed from your own logged data, not a clinical assessment or diagnosis.",
|
||||
"labs": "These are your own records, not a medical diagnosis. Reference ranges vary by lab and person — only your clinician can interpret them for you.",
|
||||
"medications": "This tracks the medications you log and your own adherence. It doesn't assess dosing or interactions and isn't medical advice — confirm those with a pharmacist or doctor.",
|
||||
"emergency": "If this might be an emergency, contact your local emergency number or a clinician now. HealthLog can't help in an emergency.",
|
||||
"dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data."
|
||||
}
|
||||
}
|
||||
+191
-71
@@ -4018,16 +4018,46 @@
|
||||
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Secciones de ajustes"
|
||||
"sectionsNav": "Secciones de ajustes",
|
||||
"subSectionsNav": "Subsecciones de ajustes"
|
||||
},
|
||||
"groups": {
|
||||
"account": {
|
||||
"title": "Cuenta"
|
||||
},
|
||||
"tracking": {
|
||||
"title": "Seguimiento"
|
||||
},
|
||||
"display": {
|
||||
"title": "Visualización"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integraciones"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones"
|
||||
},
|
||||
"ai": {
|
||||
"title": "IA y Coach"
|
||||
},
|
||||
"access": {
|
||||
"title": "API y acceso"
|
||||
},
|
||||
"data": {
|
||||
"title": "Datos y privacidad"
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Cuenta",
|
||||
"title": "Perfil y preferencias",
|
||||
"description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.",
|
||||
"subtitle": "Tu perfil, identidad y datos de acceso."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integraciones",
|
||||
"title": "Conexiones",
|
||||
"description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
|
||||
"subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos."
|
||||
},
|
||||
@@ -4036,11 +4066,10 @@
|
||||
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
|
||||
"channelsHintLink": "Integraciones → Canales",
|
||||
"inboxLink": "Abrir la bandeja de entrada",
|
||||
"subtitle": "Elige qué recordatorios recibes."
|
||||
},
|
||||
"layout": {
|
||||
"title": "Panel",
|
||||
"title": "Visualización",
|
||||
"description": "Organiza cómo están dispuestos el panel y los análisis.",
|
||||
"backToHub": "Volver a Diseño y personalización",
|
||||
"dashboard": {
|
||||
@@ -4319,16 +4348,16 @@
|
||||
"subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Datos y privacidad",
|
||||
"subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.",
|
||||
"description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad."
|
||||
"title": "Privacidad y retención",
|
||||
"subtitle": "Tus datos, su retención y el cifrado en un solo lugar.",
|
||||
"description": "Exportación, eliminación, retención y estado de cifrado."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Conector MCP",
|
||||
"subtitle": "Conecta un asistente externo a tus propios datos de salud."
|
||||
},
|
||||
"anamnesis": {
|
||||
"title": "Historial médico",
|
||||
"title": "Antecedentes médicos",
|
||||
"subtitle": "Gestiona tus alergias y tus antecedentes familiares.",
|
||||
"description": "Registros estructurados de alergias y antecedentes familiares."
|
||||
},
|
||||
@@ -4665,7 +4694,9 @@
|
||||
"focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…",
|
||||
"completeness": "{answered} de {total} respondidas",
|
||||
"questionsTitle": "El Coach tiene preguntas",
|
||||
"questionsOpenCoach": "Responderlas en el Coach"
|
||||
"questionsOpenCoach": "Responderlas en el Coach",
|
||||
"recordsLink": "Las alergias, los antecedentes familiares y tus enfermedades preexistentes forman parte de tu historia clínica.",
|
||||
"recordsLinkCta": "Abrir historia clínica"
|
||||
},
|
||||
"responseTimeoutHeading": "Tiempo de espera de la respuesta",
|
||||
"responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.",
|
||||
@@ -5286,6 +5317,11 @@
|
||||
"description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.",
|
||||
"openAi": "Modo de privacidad de IA",
|
||||
"openResearch": "Modo investigación"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Inicio de sesión y dispositivos",
|
||||
"description": "Las sesiones activas, los dispositivos de confianza y la actividad de inicio de sesión ahora están en la configuración de seguridad de tu cuenta.",
|
||||
"openSecurity": "Gestionar inicio de sesión y seguridad"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -7061,9 +7097,14 @@
|
||||
"restoredToast": "Medición restaurada",
|
||||
"restoreError": "No se pudo restaurar la medición",
|
||||
"detail": {
|
||||
"history": "Historial",
|
||||
"emptyTitle": "Aún no hay mediciones",
|
||||
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo."
|
||||
"emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo.",
|
||||
"title": "Biomarcador",
|
||||
"genericDescription": "Un biomarcador de laboratorio que sigues. Añade lecturas para ver su evolución frente al rango de referencia.",
|
||||
"valuesTitle": "{marker} — todas las lecturas",
|
||||
"valuesDescription": "Cada lectura de este biomarcador. Edita o elimina una entrada directamente.",
|
||||
"backToMarker": "Volver a {marker}",
|
||||
"latestResult": "Último resultado"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Sin mediciones en este periodo.",
|
||||
@@ -7172,36 +7213,36 @@
|
||||
"bloodCount": "Hemograma"
|
||||
},
|
||||
"desc": {
|
||||
"total-cholesterol": "Cantidad total de colesterol transportado en la sangre, suma de las fracciones LDL, HDL y otras lipoproteínas.",
|
||||
"ldl": "Colesterol transportado por lipoproteínas de baja densidad, la fracción que se deposita en las paredes arteriales.",
|
||||
"hdl": "Colesterol transportado por lipoproteínas de alta densidad, que lo devuelven al hígado.",
|
||||
"triglycerides": "La principal grasa que circula en la sangre, procedente de la dieta y producida por el hígado.",
|
||||
"fasting-glucose": "Azúcar en sangre medido en ayunas, refleja la regulación basal de la glucosa.",
|
||||
"hba1c": "Proporción de hemoglobina unida a glucosa, refleja el azúcar en sangre de los últimos dos o tres meses.",
|
||||
"tsh": "Hormona hipofisaria que regula la tiroides; la prueba principal de la función tiroidea.",
|
||||
"ft3": "La forma libre y activa de la triyodotironina, la hormona tiroidea más potente.",
|
||||
"ft4": "La forma libre de la tiroxina, la principal hormona que la tiroides libera a la sangre.",
|
||||
"ferritin": "La principal proteína de almacenamiento de hierro del cuerpo, refleja las reservas de hierro.",
|
||||
"transferrin-saturation": "Proporción de la proteína transportadora transferrina que lleva hierro en ese momento.",
|
||||
"vitamin-d": "25-hidroxivitamina D circulante, el marcador estándar del estado de vitamina D.",
|
||||
"vitamin-b12": "Vitamina esencial para la formación de glóbulos rojos y la función nerviosa.",
|
||||
"folate": "Vitamina B necesaria para la formación de glóbulos rojos y la síntesis de ADN.",
|
||||
"hs-crp": "Una proteína hepática que aumenta con la inflamación, medida con alta sensibilidad.",
|
||||
"creatinine": "Producto de desecho muscular eliminado por los riñones, usado para evaluar la función renal.",
|
||||
"egfr": "Tasa estimada de filtración de los riñones, calculada a partir de la creatinina.",
|
||||
"alt": "Enzima hepática liberada a la sangre cuando se dañan las células del hígado.",
|
||||
"ast": "Enzima del hígado y el músculo que aumenta cuando esos tejidos se lesionan.",
|
||||
"ggt": "Enzima del hígado y las vías biliares que aumenta con la obstrucción biliar o el alcohol.",
|
||||
"sodium": "Principal electrolito que regula el equilibrio de líquidos y el volumen sanguíneo.",
|
||||
"potassium": "Electrolito esencial para la transmisión nerviosa y la función del músculo cardíaco.",
|
||||
"hemoglobin": "La proteína de los glóbulos rojos que transporta el oxígeno.",
|
||||
"hematocrit": "La fracción del volumen sanguíneo formada por glóbulos rojos.",
|
||||
"wbc": "Recuento total de glóbulos blancos, la defensa circulante del sistema inmunitario.",
|
||||
"platelets": "Fragmentos celulares que se agregan para formar coágulos sanguíneos.",
|
||||
"apob": "Cuenta directamente las partículas aterogénicas; a menudo mejor marcador de riesgo que el LDL-C cuando discrepan.",
|
||||
"lp-a": "Factor de riesgo en gran parte hereditario y de por vida — suele medirse una vez. Informa en unidades molares (nmol/L).",
|
||||
"fasting-insulin": "Se combina con la glucosa en ayunas para estimar la resistencia a la insulina (HOMA-IR).",
|
||||
"omega-3-index": "Proporción de EPA+DHA en glóbulos rojos; 8–12 % es el rango cardioprotector citado habitualmente."
|
||||
"total-cholesterol": "El colesterol total es la suma del colesterol transportado por todas las partículas de lipoproteínas de la sangre, incluidas las fracciones LDL, HDL y remanentes. El colesterol es en sí mismo un componente esencial de las membranas celulares, las hormonas y los ácidos biliares. Como cifra única resulta una medida aproximada, porque tanto una proporción alta de HDL como una alta de LDL elevan el total, por lo que suele leerse junto con las fracciones individuales. Se registra sobre todo dentro de la evaluación del riesgo cardiovascular a largo plazo.",
|
||||
"ldl": "El colesterol LDL es el colesterol transportado por las partículas de lipoproteína de baja densidad, la fracción que lleva el colesterol a los tejidos pero que también puede depositarlo en las paredes arteriales. A lo largo de los años, ese depósito contribuye a la placa aterosclerótica, por lo que el LDL es uno de los marcadores lipídicos más vigilados. Una menor exposición a largo plazo suele asociarse a un menor riesgo cardiovascular. Se interpreta por categoría de riesgo y no frente a un único valor normal fijo.",
|
||||
"hdl": "El colesterol HDL es el colesterol transportado por las partículas de lipoproteína de alta densidad, que recogen el colesterol sobrante de los tejidos y lo devuelven al hígado para reciclarlo o eliminarlo. Por esa función de transporte se le suele llamar la fracción protectora. Los niveles muy bajos tienden a acompañar a un mayor riesgo cardiovascular, aunque elevarlo de forma artificial no ha reducido el riesgo de manera fiable, por lo que se lee como marcador y no como objetivo. Suele interpretarse junto con el LDL y los triglicéridos.",
|
||||
"triglycerides": "Los triglicéridos son la principal forma de grasa que circula por la sangre, aportada tanto por la dieta como producida por el hígado, y constituyen una reserva clave de energía. Los niveles suben después de comer, por lo que la muestra suele tomarse en ayunas para que sea comparable. Unos triglicéridos altos de forma persistente se asocian a riesgo metabólico y cardiovascular y a menudo acompañan a la resistencia a la insulina y a un HDL bajo. Se leen como un componente del cuadro lipídico global.",
|
||||
"fasting-glucose": "La glucosa en ayunas es la concentración de azúcar en la sangre medida tras un ayuno nocturno, y refleja cómo mantiene el cuerpo su nivel basal de azúcar sin comida reciente. La glucosa es el combustible inmediato principal del organismo, y la insulina la mantiene normalmente dentro de un rango estrecho. Una medición en ayunas elimina el efecto de las comidas recientes, lo que la convierte en un cribado estándar del funcionamiento de la regulación de la glucosa. Es uno de los marcadores con los que se evalúa la salud metabólica a lo largo del tiempo.",
|
||||
"hba1c": "La HbA1c es la proporción de hemoglobina de los glóbulos rojos que se ha unido a la glucosa, y se acumula en proporción a la exposición media al azúcar en sangre. Como los glóbulos rojos viven unos tres meses, refleja la glucosa media de los dos o tres meses anteriores y no un instante concreto. Esto la convierte en una visión estable e independiente de las comidas del control de la glucosa. Su fiabilidad puede verse afectada por trastornos que alteran la vida de los glóbulos rojos.",
|
||||
"tsh": "La TSH (hormona estimulante del tiroides) la libera la hipófisis para indicar al tiroides que produzca sus hormonas, y se ajusta al alza o a la baja para mantenerlas en equilibrio. Gracias a ese circuito de retroalimentación, la TSH es el cribado de primera línea más sensible de la función tiroidea: suele moverse antes de que las propias hormonas tiroideas se desvíen. Un valor elevado o reducido apunta a un tiroides hipo o hiperactivo y normalmente lleva a medir directamente las hormonas tiroideas. El embarazo y la edad desplazan el rango esperado.",
|
||||
"ft3": "El T3 libre (triyodotironina) es la forma no unida y biológicamente activa de la hormona tiroidea más potente, la fracción disponible para actuar sobre los tejidos. La mayor parte del T3 circulante está unida a proteínas transportadoras e inactiva, de modo que la porción libre refleja la disponibilidad real de la hormona. Se mide para caracterizar la función tiroidea, sobre todo junto con la TSH y el T4 libre cuando los resultados no están claros. Los niveles dependen mucho del método de análisis y se leen en su contexto clínico.",
|
||||
"ft4": "El T4 libre (tiroxina) es la forma no unida de la principal hormona que el tiroides libera a la sangre, y actúa como reserva que los tejidos convierten en el T3 más activo según lo necesiten. La fracción libre refleja lo que realmente está disponible para el cuerpo, con independencia del nivel de proteínas transportadoras. Suele medirse junto con la TSH para distinguir los problemas del tiroides de los de la señalización hipofisaria. El embarazo y ciertos medicamentos pueden desplazarlo.",
|
||||
"ferritin": "La ferritina es la principal proteína de almacenamiento de hierro del cuerpo, y su nivel en sangre refleja a grandes rasgos las reservas totales de hierro. Un valor bajo apunta a unas reservas agotadas, mientras que uno alto puede reflejar una sobrecarga de hierro, o simplemente una inflamación, ya que la ferritina también sube como reactante de fase aguda. Por eso suele interpretarse junto con otros marcadores de hierro y signos de inflamación. Es una de las medidas centrales para evaluar el estado del hierro.",
|
||||
"transferrin-saturation": "La saturación de transferrina es el porcentaje de transferrina —la proteína que transporta el hierro por la sangre— que está cargado de hierro en ese momento. Relaciona la cantidad de hierro en tránsito con la capacidad de transporte del cuerpo, ofreciendo una instantánea de la disponibilidad de hierro que complementa la visión de las reservas que da la ferritina. Una saturación baja sugiere que el hierro escasea, mientras que un valor alto puede indicar sobrecarga. Se lee junto con la ferritina y el perfil completo del hierro.",
|
||||
"vitamin-d": "La vitamina D se mide como 25-hidroxivitamina D, la forma de almacenamiento circulante y el marcador estándar del estado global de vitamina D. El cuerpo la fabrica en la piel expuesta a la luz solar y absorbe una parte de los alimentos, y contribuye al equilibrio del calcio, la salud ósea y la función inmunitaria. Los niveles varían con la estación, la latitud, el tono de piel y la suplementación. Se registra para valorar a lo largo del tiempo si las reservas son adecuadas.",
|
||||
"vitamin-b12": "La vitamina B12 (cobalamina) es una vitamina esencial que se obtiene sobre todo de alimentos de origen animal y se almacena en gran parte en el hígado. Es necesaria para la formación de glóbulos rojos, la síntesis de ADN y la salud del sistema nervioso. Una carencia prolongada —por baja ingesta o absorción deficiente— puede provocar anemia y síntomas neurológicos. Se mide para evaluar el estado nutricional, en especial cuando la ingesta o la absorción pueden estar limitadas.",
|
||||
"folate": "El folato (vitamina B9) es una vitamina del grupo B presente en las verduras de hoja verde, las legumbres y los alimentos enriquecidos, central para la formación de glóbulos rojos y la síntesis de ADN. Trabaja en estrecha relación con la vitamina B12, y una carencia de cualquiera de las dos puede producir un tipo de anemia similar. Un folato adecuado es especialmente importante en torno al inicio del embarazo para el desarrollo sano del feto. Se mide para evaluar el estado nutricional y la causa de ciertas anemias.",
|
||||
"hs-crp": "La PCR de alta sensibilidad mide la proteína C reactiva, un marcador que el hígado libera en respuesta a la inflamación, con una técnica afinada para detectar las bajas concentraciones relevantes para el riesgo cardiovascular. A diferencia de la PCR estándar usada para infecciones agudas, la versión de alta sensibilidad capta la inflamación sutil y crónica vinculada a la enfermedad vascular. Como cualquier infección o lesión reciente puede elevarla, se interpreta lejos de una enfermedad aguda y a menudo se confirma con una muestra repetida. Se lee como un factor más del riesgo cardiovascular global.",
|
||||
"creatinine": "La creatinina es un producto de desecho del metabolismo muscular normal que los riñones filtran de la sangre a un ritmo bastante constante. Como la producción es relativamente estable, su nivel en sangre sirve de medida práctica de la eficacia con que los riñones eliminan los desechos. Los valores también dependen de la masa muscular, la hidratación y la dieta, por lo que suelen interpretarse junto con la tasa de filtración estimada. Es un marcador central de la función renal.",
|
||||
"egfr": "La eGFR (tasa de filtración glomerular estimada) calcula cuánta sangre filtran los riñones cada minuto, a partir de la creatinina junto con la edad y el sexo. Traduce un valor bruto de creatinina en una medida de la función renal más fácil de interpretar. Un descenso de la eGFR con el tiempo es la principal forma de clasificar y seguir los cambios renales crónicos. Al ser una estimación, se lee como una tendencia y no como una cifra exacta única.",
|
||||
"alt": "La ALT (alanina aminotransferasa) es una enzima concentrada en las células del hígado, que se libera a la sangre cuando esas células están estresadas o dañadas. Por ser relativamente específica del hígado, es uno de los principales marcadores para detectar lesión o inflamación hepática. Sus valores pueden subir con el hígado graso, la hepatitis viral, el alcohol o ciertos medicamentos. Suele leerse junto con la AST y el resto de los marcadores del perfil hepático.",
|
||||
"ast": "La AST (aspartato aminotransferasa) es una enzima presente en el hígado, pero también en el músculo, el corazón y los glóbulos rojos, que se libera cuando cualquiera de esos tejidos sufre daño. Por ser menos específica del hígado que la ALT, ambas se interpretan juntas, y su cociente puede orientar sobre el origen y la naturaleza del problema. El ejercicio intenso o una lesión muscular pueden elevarla al margen del hígado. Es una de las enzimas estándar del perfil hepático.",
|
||||
"ggt": "La GGT (gamma-glutamil transferasa) es una enzima del hígado y las vías biliares que sube en especial cuando se obstruye el flujo de la bilis o ante el consumo de alcohol. A menudo se usa para aclarar por qué está elevado otro marcador hepático, por ejemplo, para distinguir un origen hepático o biliar. Es sensible pero poco específica, por lo que se lee en el contexto del resto del perfil hepático. Es uno de los varios marcadores que se usan para valorar la salud hepática y biliar.",
|
||||
"sodium": "El sodio es el principal electrolito del líquido que rodea las células del cuerpo y el motor fundamental del equilibrio de líquidos y del volumen sanguíneo. El organismo lo regula de forma estricta mediante la sed, las hormonas y los riñones, de modo que su nivel en sangre refleja más el equilibrio entre el agua y la sal que la ingesta por sí sola. Los valores anómalos suelen indicar una alteración del equilibrio de agua y no del propio sodio. Es una parte central del panel metabólico básico y de electrolitos.",
|
||||
"potassium": "El potasio es el principal electrolito del interior de las células del cuerpo y es esencial para la transmisión nerviosa y el ritmo estable del músculo cardíaco. Su nivel en sangre se mantiene en un rango estrecho gracias a los riñones y a las señales hormonales, porque tanto los valores altos como los bajos pueden alterar la actividad eléctrica del corazón. Los niveles pueden verse afectados por la función renal, la hidratación y varios medicamentos habituales. Es una medida central del panel de electrolitos.",
|
||||
"hemoglobin": "La hemoglobina es la proteína con hierro de los glóbulos rojos que fija el oxígeno en los pulmones y lo transporta a los tejidos. Su concentración es la principal medida de la capacidad de transporte de oxígeno de la sangre y el marcador central para detectar la anemia. Los valores bajos pueden reflejar pérdida de sangre, déficit de hierro o de vitaminas, o una producción reducida, mientras que los altos pueden seguir a la deshidratación o a condiciones de poco oxígeno. Es una piedra angular del hemograma completo.",
|
||||
"hematocrit": "El hematocrito es la proporción del volumen de sangre formada por glóbulos rojos, expresada como porcentaje. Se mueve en estrecha relación con la hemoglobina y ofrece una visión complementaria de la masa de glóbulos rojos y de la capacidad de transporte de oxígeno de la sangre. La hidratación influye en él, ya que los cambios del volumen de plasma desplazan la proporción aunque el número de glóbulos rojos se mantenga estable. Se lee junto con la hemoglobina y el resto del hemograma.",
|
||||
"wbc": "El recuento de leucocitos es el número total de células inmunitarias que circulan por la sangre, la defensa móvil del cuerpo frente a las infecciones. Sube en respuesta a infección, inflamación o estrés y puede bajar cuando la médula ósea está suprimida o en algunas enfermedades inmunitarias. Como reúne varios tipos de células distintas, un cambio notable suele completarse con una fórmula que lo desglosa. Es un componente central del hemograma completo.",
|
||||
"platelets": "Las plaquetas son pequeños fragmentos celulares de la sangre que se agregan y forman el tapón inicial que detiene el sangrado de un vaso dañado. Su recuento refleja el equilibrio entre la producción en la médula ósea y el consumo o la destrucción. Un recuento bajo puede aumentar la tendencia al sangrado, mientras que uno alto puede seguir a una inflamación u otros estímulos. Forman parte del hemograma completo y son una medida básica de la capacidad de coagulación.",
|
||||
"apob": "La apolipoproteína B es la proteína estructural que lleva cada una de las partículas de lipoproteína aterogénicas, de modo que medirla cuenta directamente esas partículas en lugar del colesterol que transportan. Como hay una molécula de ApoB en cada partícula de LDL, VLDL y afines, puede captar un riesgo cardiovascular que un valor de colesterol LDL pasa por alto, sobre todo cuando el número de partículas y su contenido de colesterol divergen. Por eso se usa cada vez más para afinar la evaluación del riesgo basada en los lípidos. Se interpreta junto con el perfil lipídico habitual.",
|
||||
"lp-a": "La lipoproteína(a) es una partícula similar al LDL con una proteína adicional unida, cuyo nivel viene determinado en gran medida por la genética y se mantiene bastante estable a lo largo de la vida. Una Lp(a) elevada es un factor de riesgo independiente y hereditario de enfermedad cardiovascular y de estrechamiento de la válvula aórtica. Como cambia poco, suele medirse una sola vez para establecer el riesgo vital de una persona, en lugar de seguirla de forma repetida. Se expresa en unidades molares y se lee como parte del riesgo cardiovascular global.",
|
||||
"fasting-insulin": "La insulina en ayunas es el nivel de insulina —la hormona que traslada la glucosa de la sangre a las células— medido tras un ayuno nocturno. Junto con la glucosa en ayunas, ayuda a estimar la resistencia a la insulina, el estado en el que el cuerpo necesita más insulina para mantener la glucosa dentro de su rango. Una insulina en ayunas alta con una glucosa normal puede ser un signo temprano de que esa compensación está en marcha. Se lee como parte de un cuadro de salud metabólica y no de forma aislada.",
|
||||
"omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses."
|
||||
},
|
||||
"apob": "Apolipoproteína B (ApoB)",
|
||||
"lp-a": "Lipoproteína(a)",
|
||||
@@ -7318,8 +7359,8 @@
|
||||
"description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Documentos entrantes",
|
||||
"description": "Sube un informe médico o un alta; revisa los datos extraídos antes de guardarlos. Desactivado de forma predeterminada."
|
||||
"label": "Documentos",
|
||||
"description": "Guarda tus documentos clínicos; opcionalmente extrae y revisa sus datos antes de guardarlos. Desactivado por defecto."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Bienestar mental",
|
||||
@@ -7558,8 +7599,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Revisión de bienestar mental",
|
||||
"pageDescription": "Dos autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo) y GAD-7 (preocupación) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
|
||||
"disclaimer": "Esto es una autoevaluación de cribado, no un diagnóstico. Una puntuación alta o baja no significa que tengas o no una afección. Solo un profesional de salud cualificado puede diagnosticar. Si tienes dudas, habla con tu médico.",
|
||||
"optInNote": "Opcional y privado: tus respuestas individuales están cifradas, se mantienen fuera del Coach de IA y de las exportaciones, y nunca se comparten.",
|
||||
"infoTooltip": "Esto es una autoevaluación de cribado, no un diagnóstico.",
|
||||
"attributionLabel": "Fuente",
|
||||
"choosePrompt": "Elige una revisión",
|
||||
"start": "Empezar",
|
||||
@@ -7774,6 +7814,15 @@
|
||||
"updatedToast": "Entrada actualizada",
|
||||
"saveError": "No se ha podido guardar la entrada."
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Enfermedades preexistentes",
|
||||
"cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar, desde tu contexto personal.",
|
||||
"conditionsLabel": "Enfermedades crónicas",
|
||||
"focusLabel": "Qué vigila el Coach",
|
||||
"empty": "Aún no hay nada anotado.",
|
||||
"editCta": "Editar en el contexto personal",
|
||||
"loadError": "No se pudieron cargar tus enfermedades."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -7788,37 +7837,50 @@
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documentos entrantes",
|
||||
"subtitle": "Sube un documento clínico y revisa los datos que hemos leído.",
|
||||
"disclaimer": {
|
||||
"title": "Leemos, no interpretamos",
|
||||
"body": "Solo transcribimos lo que indica tu documento — valores, códigos y fechas — y nunca diagnosticamos, interpretamos ni marcamos nada. No se guarda nada hasta que lo revises y lo apruebes. Solo tu profesional sanitario puede interpretar estos resultados."
|
||||
},
|
||||
"title": "Documentos",
|
||||
"subtitle": "Guarda tus documentos clínicos y revisa lo que dicen.",
|
||||
"upload": {
|
||||
"label": "Subir un documento",
|
||||
"kindLabel": "Tipo de documento",
|
||||
"label": "Añadir un documento",
|
||||
"kindLabel": "Categoría",
|
||||
"button": "Subir",
|
||||
"uploading": "Leyendo…",
|
||||
"fileLabel": "Archivo del documento"
|
||||
"uploading": "Subiendo…",
|
||||
"fileLabel": "Archivo (obligatorio)",
|
||||
"titleLabel": "Título (opcional)",
|
||||
"titlePlaceholder": "p. ej. Informe de cardiología",
|
||||
"dateLabel": "Fecha del documento (opcional)",
|
||||
"fileRequired": "Elige un archivo para subir.",
|
||||
"accepts": "JPEG, PNG, WebP o PDF · hasta 12 MB"
|
||||
},
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Informe médico",
|
||||
"DISCHARGE_LETTER": "Alta hospitalaria",
|
||||
"OTHER": "Otro"
|
||||
"DISCHARGE_LETTER": "Informe de alta",
|
||||
"OTHER": "Otro",
|
||||
"LAB_RESULT": "Resultado de laboratorio",
|
||||
"IMAGING": "Imagen",
|
||||
"PRESCRIPTION": "Receta",
|
||||
"INSURANCE": "Seguro",
|
||||
"VACCINATION": "Vacunación"
|
||||
},
|
||||
"list": {
|
||||
"empty": "Aún no hay documentos."
|
||||
"empty": "Aún no hay documentos.",
|
||||
"noResults": "Ningún documento coincide con tus filtros.",
|
||||
"loadMore": "Cargar más",
|
||||
"loadingMore": "Cargando…",
|
||||
"pending": "{count} por revisar",
|
||||
"facts": "{count} datos",
|
||||
"clearFilters": "Borrar filtros",
|
||||
"loadError": "No se pudieron cargar tus documentos."
|
||||
},
|
||||
"status": {
|
||||
"EXTRACTING": "Leyendo",
|
||||
"EXTRACTED": "Listo para revisar",
|
||||
"FAILED": "No se pudo leer",
|
||||
"CONFIRMED": "Revisado",
|
||||
"DISCARDED": "Descartado"
|
||||
"DISCARDED": "Descartado",
|
||||
"STORED": "Guardado"
|
||||
},
|
||||
"review": {
|
||||
"title": "Revisar datos extraídos",
|
||||
"discard": "Descartar",
|
||||
"confirm": "Guardar datos aprobados",
|
||||
"confirming": "Guardando…",
|
||||
"needsReview": "Requiere revisión",
|
||||
@@ -7826,7 +7888,14 @@
|
||||
"reject": "Rechazar",
|
||||
"edit": "Editar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar"
|
||||
"save": "Guardar",
|
||||
"needsReviewHint": "Edita este dato para poder aprobarlo.",
|
||||
"confidenceLabel": "Confianza de la extracción",
|
||||
"commitError": {
|
||||
"unitMismatch": "la unidad no coincide con la guardada",
|
||||
"unitRequired": "un valor numérico necesita una unidad",
|
||||
"generic": "no se pudo guardar"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noFacts": "Nada más que revisar."
|
||||
@@ -7844,21 +7913,72 @@
|
||||
"dose": "Dosis"
|
||||
},
|
||||
"toast": {
|
||||
"uploaded": "Documento leído. Revisa los datos a continuación.",
|
||||
"uploadFailed": "No se pudo leer el documento.",
|
||||
"uploaded": "Documento guardado.",
|
||||
"uploadFailed": "No se pudo guardar el documento.",
|
||||
"confirmed": "Datos aprobados guardados.",
|
||||
"confirmedPartial": "Guardado, pero {count} no se pudieron guardar: {reason}.",
|
||||
"confirmFailed": "No se pudieron guardar tus decisiones.",
|
||||
"saved": "Dato actualizado.",
|
||||
"discarded": "Documento descartado."
|
||||
"discarded": "Documento descartado.",
|
||||
"extracted": "Datos extraídos. Revísalos abajo.",
|
||||
"extractFailed": "No se pudieron extraer los datos.",
|
||||
"localOcrDisabled": "Activa el OCR local en los ajustes para leer este documento en el dispositivo.",
|
||||
"updated": "Documento actualizado.",
|
||||
"updateFailed": "No se pudo actualizar el documento.",
|
||||
"discardFailed": "No se pudo descartar el documento.",
|
||||
"uploadTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
|
||||
"uploadFileType": "Sube un archivo JPEG, PNG, WebP o PDF.",
|
||||
"uploadRateLimited": "Demasiadas subidas. Inténtalo de nuevo más tarde.",
|
||||
"uploadInvalidMetadata": "Revisa el título, la categoría o la fecha e inténtalo de nuevo."
|
||||
},
|
||||
"toolbar": {
|
||||
"searchLabel": "Buscar",
|
||||
"searchPlaceholder": "Título o nombre de archivo",
|
||||
"kindFilterLabel": "Categoría",
|
||||
"allKinds": "Todas las categorías",
|
||||
"sortLabel": "Ordenar por",
|
||||
"sort": {
|
||||
"documentDate": "Fecha del documento",
|
||||
"createdAt": "Fecha de adición",
|
||||
"title": "Título"
|
||||
},
|
||||
"orderAsc": "Ascendente",
|
||||
"orderDesc": "Descendente",
|
||||
"fromLabel": "Desde",
|
||||
"toLabel": "Hasta"
|
||||
},
|
||||
"detail": {
|
||||
"viewOriginal": "Ver original",
|
||||
"edit": "Editar",
|
||||
"extract": "Extraer datos",
|
||||
"extracting": "Extrayendo…",
|
||||
"discard": "Descartar",
|
||||
"notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento.",
|
||||
"viewOriginalNewTab": "(se abre en una pestaña nueva)",
|
||||
"discardConfirmTitle": "¿Descartar este documento?",
|
||||
"discardConfirmBody": "Esto elimina de forma permanente el documento, su archivo original y los datos preparados. No se puede deshacer.",
|
||||
"discardConfirmAction": "Descartar",
|
||||
"discardCancel": "Cancelar"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Editar documento",
|
||||
"subtitle": "Renombra, recategoriza o fija la fecha del documento.",
|
||||
"titleLabel": "Título",
|
||||
"kindLabel": "Categoría",
|
||||
"dateLabel": "Fecha del documento",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando…",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"note": {
|
||||
"providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.",
|
||||
"alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer."
|
||||
},
|
||||
"committed": {
|
||||
"title": "Aprobado de este documento"
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog es una herramienta personal de registro de salud y bienestar. Te ayuda a registrar, visualizar y exportar tus propios datos de salud. No diagnostica, trata, cura ni previene ninguna enfermedad, y no es un producto sanitario.",
|
||||
"coach": "El Coach trabaja con tus propios datos registrados. No es un médico y no da consejo médico: para un diagnóstico o tratamiento, consulta a un profesional sanitario.",
|
||||
"insights": "Son patrones descriptivos calculados a partir de tus propios datos registrados, no una evaluación clínica ni un diagnóstico.",
|
||||
"labs": "Son tus propios registros, no un diagnóstico médico. Los rangos de referencia varían según el laboratorio y la persona: solo tu médico puede interpretarlos por ti.",
|
||||
"medications": "Esto registra los medicamentos que anotas y tu propia adherencia. No evalúa dosis ni interacciones y no es consejo médico: confírmalo con tu farmacéutico o médico.",
|
||||
"emergency": "Si esto pudiera ser una emergencia, llama ahora a tu número de emergencias local o a un profesional sanitario. HealthLog no puede ayudar en una emergencia.",
|
||||
"dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud."
|
||||
}
|
||||
}
|
||||
+190
-70
@@ -4018,16 +4018,46 @@
|
||||
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sections des réglages"
|
||||
"sectionsNav": "Sections des réglages",
|
||||
"subSectionsNav": "Sous-sections des réglages"
|
||||
},
|
||||
"groups": {
|
||||
"account": {
|
||||
"title": "Compte"
|
||||
},
|
||||
"tracking": {
|
||||
"title": "Suivi"
|
||||
},
|
||||
"display": {
|
||||
"title": "Affichage"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Intégrations"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications"
|
||||
},
|
||||
"ai": {
|
||||
"title": "IA et coach"
|
||||
},
|
||||
"access": {
|
||||
"title": "API et accès"
|
||||
},
|
||||
"data": {
|
||||
"title": "Données et confidentialité"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Compte",
|
||||
"title": "Profil et préférences",
|
||||
"description": "Profil, mot de passe, clés d’accès et votre visite guidée.",
|
||||
"subtitle": "Votre profil, votre identité et vos identifiants."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Intégrations",
|
||||
"title": "Connexions",
|
||||
"description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
|
||||
"subtitle": "Connectez et gérez vos sources de données et appareils."
|
||||
},
|
||||
@@ -4036,11 +4066,10 @@
|
||||
"description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
|
||||
"channelsHintLink": "Intégrations → Canaux",
|
||||
"inboxLink": "Ouvrir la boîte de réception",
|
||||
"subtitle": "Choisissez les rappels que vous recevez."
|
||||
},
|
||||
"layout": {
|
||||
"title": "Tableau de bord",
|
||||
"title": "Affichage",
|
||||
"description": "Organisez la disposition du tableau de bord et des analyses.",
|
||||
"backToHub": "Retour à Disposition et personnalisation",
|
||||
"dashboard": {
|
||||
@@ -4319,9 +4348,9 @@
|
||||
"subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Données et confidentialité",
|
||||
"subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.",
|
||||
"description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité."
|
||||
"title": "Confidentialité et conservation",
|
||||
"subtitle": "Vos données, leur conservation et le chiffrement au même endroit.",
|
||||
"description": "Export, suppression, conservation et état du chiffrement."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Connecteur MCP",
|
||||
@@ -4665,7 +4694,9 @@
|
||||
"focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…",
|
||||
"completeness": "{answered} sur {total} renseignés",
|
||||
"questionsTitle": "Le Coach a des questions",
|
||||
"questionsOpenCoach": "Y répondre dans le Coach"
|
||||
"questionsOpenCoach": "Y répondre dans le Coach",
|
||||
"recordsLink": "Les allergies, les antécédents familiaux et vos affections préexistantes font partie de vos antécédents médicaux.",
|
||||
"recordsLinkCta": "Ouvrir les antécédents médicaux"
|
||||
},
|
||||
"responseTimeoutHeading": "Délai de réponse",
|
||||
"responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.",
|
||||
@@ -5286,6 +5317,11 @@
|
||||
"description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.",
|
||||
"openAi": "Mode confidentialité IA",
|
||||
"openResearch": "Mode recherche"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Connexion et appareils",
|
||||
"description": "Les sessions actives, les appareils de confiance et l'activité de connexion se trouvent désormais dans les paramètres de sécurité de votre compte.",
|
||||
"openSecurity": "Gérer la connexion et la sécurité"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -7061,9 +7097,14 @@
|
||||
"restoredToast": "Mesure restaurée",
|
||||
"restoreError": "Impossible de restaurer la mesure",
|
||||
"detail": {
|
||||
"history": "Historique",
|
||||
"emptyTitle": "Aucune mesure pour l'instant",
|
||||
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps."
|
||||
"emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps.",
|
||||
"title": "Biomarqueur",
|
||||
"genericDescription": "Un biomarqueur biologique que vous suivez. Ajoutez des mesures pour observer son évolution par rapport à la plage de référence.",
|
||||
"valuesTitle": "{marker} — toutes les valeurs",
|
||||
"valuesDescription": "Chaque mesure de ce biomarqueur. Modifiez ou supprimez une entrée directement.",
|
||||
"backToMarker": "Retour à {marker}",
|
||||
"latestResult": "Dernier résultat"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Aucune mesure sur cette période.",
|
||||
@@ -7172,36 +7213,36 @@
|
||||
"bloodCount": "Numération sanguine"
|
||||
},
|
||||
"desc": {
|
||||
"total-cholesterol": "Quantité totale de cholestérol transporté dans le sang, somme des fractions LDL, HDL et autres lipoprotéines.",
|
||||
"ldl": "Cholestérol transporté par les lipoprotéines de basse densité, la fraction qui se dépose dans les parois artérielles.",
|
||||
"hdl": "Cholestérol transporté par les lipoprotéines de haute densité, qui le ramènent vers le foie.",
|
||||
"triglycerides": "La principale graisse circulant dans le sang, issue de l'alimentation et produite par le foie.",
|
||||
"fasting-glucose": "Glycémie mesurée à jeun, reflétant la régulation de base du glucose.",
|
||||
"hba1c": "Part de l'hémoglobine liée au glucose, reflet de la glycémie des deux à trois derniers mois.",
|
||||
"tsh": "Hormone hypophysaire qui régule la thyroïde ; le principal test de la fonction thyroïdienne.",
|
||||
"ft3": "La forme libre et active de la triiodothyronine, l'hormone thyroïdienne la plus puissante.",
|
||||
"ft4": "La forme libre de la thyroxine, la principale hormone que la thyroïde libère dans le sang.",
|
||||
"ferritin": "La principale protéine de stockage du fer, reflet des réserves en fer.",
|
||||
"transferrin-saturation": "Part de la transferrine, protéine de transport du fer, qui porte du fer à un instant donné.",
|
||||
"vitamin-d": "25-hydroxyvitamine D circulante, le marqueur de référence du statut en vitamine D.",
|
||||
"vitamin-b12": "Vitamine essentielle à la formation des globules rouges et à la fonction nerveuse.",
|
||||
"folate": "Vitamine B nécessaire à la formation des globules rouges et à la synthèse de l'ADN.",
|
||||
"hs-crp": "Une protéine hépatique qui s'élève avec l'inflammation, mesurée à haute sensibilité.",
|
||||
"creatinine": "Déchet musculaire éliminé par les reins, utilisé pour évaluer la fonction rénale.",
|
||||
"egfr": "Débit de filtration estimé des reins, calculé à partir de la créatinine.",
|
||||
"alt": "Enzyme hépatique libérée dans le sang lorsque les cellules du foie sont endommagées.",
|
||||
"ast": "Enzyme du foie et du muscle qui s'élève lorsque ces tissus sont lésés.",
|
||||
"ggt": "Enzyme du foie et des voies biliaires qui s'élève en cas d'obstruction biliaire ou d'alcool.",
|
||||
"sodium": "Principal électrolyte régulant l'équilibre hydrique et le volume sanguin.",
|
||||
"potassium": "Électrolyte essentiel à la conduction nerveuse et à la fonction du muscle cardiaque.",
|
||||
"hemoglobin": "La protéine des globules rouges qui transporte l'oxygène.",
|
||||
"hematocrit": "La fraction du volume sanguin constituée de globules rouges.",
|
||||
"wbc": "Nombre total de globules blancs, la défense circulante du système immunitaire.",
|
||||
"platelets": "Fragments cellulaires qui s'agrègent pour former des caillots sanguins.",
|
||||
"apob": "Compte directement les particules athérogènes ; souvent un meilleur marqueur de risque que le LDL-C en cas de discordance.",
|
||||
"lp-a": "Facteur de risque en grande partie héréditaire et permanent — mesuré une seule fois en général. À exprimer en unités molaires (nmol/L).",
|
||||
"fasting-insulin": "Se combine à la glycémie à jeun pour estimer la résistance à l'insuline (HOMA-IR).",
|
||||
"omega-3-index": "Part d'EPA+DHA dans les globules rouges ; 8–12 % est la plage cardioprotectrice habituellement citée."
|
||||
"total-cholesterol": "Le cholestérol total est la somme du cholestérol transporté par l'ensemble des particules de lipoprotéines du sang, y compris les fractions LDL, HDL et les résidus. Le cholestérol est lui-même un constituant essentiel des membranes cellulaires, des hormones et des acides biliaires. En tant que chiffre unique, il reste une mesure grossière, car une part élevée de HDL comme une part élevée de LDL font monter le total ; on le lit donc généralement avec les fractions individuelles. Il est suivi surtout dans le cadre de l'évaluation du risque cardiovasculaire à long terme.",
|
||||
"ldl": "Le cholestérol LDL est le cholestérol transporté par les particules de lipoprotéines de basse densité, la fraction qui livre le cholestérol aux tissus mais peut aussi le déposer dans la paroi des artères. Au fil des années, ce dépôt contribue à la plaque d'athérosclérose, ce qui fait du LDL l'un des marqueurs lipidiques les plus surveillés. Une exposition plus faible sur le long terme s'accompagne en général d'un risque cardiovasculaire moindre. Il s'interprète par catégorie de risque, et non face à une seule valeur normale fixe.",
|
||||
"hdl": "Le cholestérol HDL est le cholestérol transporté par les particules de lipoprotéines de haute densité, qui récupèrent le cholestérol en excès dans les tissus et le ramènent au foie pour le recycler ou l'éliminer. En raison de ce rôle de transport, on le qualifie souvent de fraction protectrice. Des taux très bas tendent à accompagner un risque cardiovasculaire plus élevé, mais l'augmenter artificiellement n'a pas réduit le risque de façon fiable ; on le lit donc comme un marqueur et non comme une cible. Il s'interprète habituellement avec le LDL et les triglycérides.",
|
||||
"triglycerides": "Les triglycérides sont la principale forme de graisse circulant dans le sang, apportée à la fois par l'alimentation et produite par le foie, et ils constituent une réserve d'énergie essentielle. Les taux montent après les repas ; le prélèvement se fait donc généralement à jeun pour être comparable. Des triglycérides durablement élevés sont associés à un risque métabolique et cardiovasculaire et accompagnent souvent une résistance à l'insuline et un HDL bas. Ils se lisent comme une composante du bilan lipidique global.",
|
||||
"fasting-glucose": "La glycémie à jeun est la concentration de sucre dans le sang mesurée après un jeûne nocturne, reflétant la façon dont le corps maintient son niveau de base sans repas récent. Le glucose est le carburant immédiat principal de l'organisme, et l'insuline le maintient normalement dans une plage étroite. Une mesure à jeun écarte l'effet des repas récents, ce qui en fait un dépistage standard du fonctionnement de la régulation du glucose. C'est l'un des marqueurs utilisés pour évaluer la santé métabolique dans le temps.",
|
||||
"hba1c": "L'HbA1c est la part de l'hémoglobine des globules rouges qui s'est liée au glucose, et qui s'accumule proportionnellement à l'exposition moyenne au sucre dans le sang. Comme les globules rouges vivent environ trois mois, elle reflète la glycémie moyenne des deux à trois mois précédents plutôt qu'un instant donné. Cela en fait une vue stable et indépendante des repas du contrôle de la glycémie. Sa fiabilité peut être altérée par des affections qui modifient la durée de vie des globules rouges.",
|
||||
"tsh": "La TSH (hormone thyréostimulante) est libérée par l'hypophyse pour signaler à la thyroïde de produire ses hormones, et elle s'ajuste à la hausse ou à la baisse pour les maintenir en équilibre. Grâce à cette boucle de rétroaction, la TSH est le dépistage de première intention le plus sensible de la fonction thyroïdienne : elle bouge généralement avant que les hormones thyroïdiennes elles-mêmes ne dévient. Une valeur élevée ou abaissée oriente vers une thyroïde hypo ou hyperactive et conduit habituellement à doser directement les hormones thyroïdiennes. La grossesse et l'âge décalent la plage attendue.",
|
||||
"ft3": "La T3 libre (triiodothyronine) est la forme non liée et biologiquement active de l'hormone thyroïdienne la plus puissante, la fraction disponible pour agir sur les tissus. La majeure partie de la T3 circulante est liée à des protéines de transport et inactive ; la part libre reflète donc la disponibilité réelle de l'hormone. Elle est mesurée pour caractériser la fonction thyroïdienne, surtout avec la TSH et la T4 libre lorsque les résultats sont incertains. Les taux dépendent fortement de la technique de dosage et se lisent dans leur contexte clinique.",
|
||||
"ft4": "La T4 libre (thyroxine) est la forme non liée de la principale hormone que la thyroïde libère dans le sang ; elle sert de réservoir que les tissus convertissent en T3 plus active selon les besoins. La fraction libre reflète ce qui est réellement disponible pour le corps, indépendamment du taux de protéines de transport. On la mesure généralement avec la TSH pour distinguer un problème de la thyroïde d'un trouble de la signalisation hypophysaire. La grossesse et certains médicaments peuvent la décaler.",
|
||||
"ferritin": "La ferritine est la principale protéine de stockage du fer de l'organisme, et son taux sanguin reflète globalement les réserves totales en fer. Une valeur basse oriente vers des réserves épuisées, tandis qu'une valeur élevée peut traduire une surcharge en fer — ou simplement une inflammation, car la ferritine monte aussi comme protéine de la phase aiguë. Pour cette raison, elle s'interprète souvent avec d'autres marqueurs du fer et des signes d'inflammation. C'est l'une des mesures centrales pour évaluer le statut en fer.",
|
||||
"transferrin-saturation": "Le coefficient de saturation de la transferrine est le pourcentage de transferrine — la protéine qui transporte le fer dans le sang — actuellement chargé en fer. Il relie la quantité de fer en circulation à la capacité de transport de l'organisme, offrant un instantané de la disponibilité du fer qui complète la vue des réserves donnée par la ferritine. Une saturation basse suggère un fer en quantité insuffisante, tandis qu'une valeur élevée peut indiquer une surcharge. Elle se lit avec la ferritine et le bilan martial complet.",
|
||||
"vitamin-d": "La vitamine D se mesure sous forme de 25-hydroxyvitamine D, la forme de stockage circulante et le marqueur standard du statut global en vitamine D. Le corps la fabrique dans la peau exposée à la lumière du soleil et en absorbe une partie par l'alimentation ; elle soutient l'équilibre du calcium, la santé osseuse et la fonction immunitaire. Les taux varient selon la saison, la latitude, le teint de peau et la supplémentation. Elle est suivie pour estimer dans le temps si les réserves sont suffisantes.",
|
||||
"vitamin-b12": "La vitamine B12 (cobalamine) est une vitamine essentielle, obtenue surtout à partir d'aliments d'origine animale et stockée en grande partie dans le foie. Elle est nécessaire à la formation des globules rouges, à la synthèse de l'ADN et à la santé du système nerveux. Une carence prolongée — par faible apport ou absorption altérée — peut entraîner une anémie et des symptômes neurologiques. Elle est mesurée pour évaluer le statut nutritionnel, en particulier lorsque l'apport ou l'absorption peuvent être limités.",
|
||||
"folate": "Le folate (vitamine B9) est une vitamine du groupe B présente dans les légumes à feuilles vertes, les légumineuses et les aliments enrichis, centrale pour la formation des globules rouges et la synthèse de l'ADN. Il travaille en étroite collaboration avec la vitamine B12, et une carence de l'un ou de l'autre peut produire un type d'anémie similaire. Un apport suffisant en folate est particulièrement important autour du début de la grossesse pour un développement sain du fœtus. Il est mesuré pour évaluer le statut nutritionnel et la cause de certaines anémies.",
|
||||
"hs-crp": "La CRP ultrasensible mesure la protéine C-réactive, un marqueur que le foie libère en réponse à l'inflammation, avec une technique affinée pour détecter les faibles concentrations pertinentes pour le risque cardiovasculaire. Contrairement à la CRP standard utilisée pour les infections aiguës, la version ultrasensible capte l'inflammation discrète et chronique liée à la maladie vasculaire. Comme toute infection ou blessure récente peut l'élever, elle s'interprète à distance d'une affection aiguë et se confirme souvent par un prélèvement répété. Elle se lit comme un facteur parmi d'autres du risque cardiovasculaire global.",
|
||||
"creatinine": "La créatinine est un déchet du métabolisme musculaire normal que les reins filtrent du sang à un rythme assez constant. Comme la production est relativement stable, son taux sanguin sert de jauge pratique de la capacité des reins à éliminer les déchets. Les valeurs dépendent aussi de la masse musculaire, de l'hydratation et de l'alimentation ; on les interprète donc généralement avec le débit de filtration estimé. C'est un marqueur central de la fonction rénale.",
|
||||
"egfr": "Le DFG estimé (débit de filtration glomérulaire) estime la quantité de sang que les reins filtrent chaque minute, calculée à partir de la créatinine, de l'âge et du sexe. Il traduit une valeur brute de créatinine en une mesure plus directement interprétable de la fonction rénale. Un DFG qui baisse au fil du temps est le principal moyen de classer et de suivre les atteintes rénales chroniques. En tant qu'estimation, il se lit comme une tendance plutôt que comme un chiffre exact unique.",
|
||||
"alt": "L'ALAT (alanine aminotransférase) est une enzyme concentrée dans les cellules du foie, libérée dans le sang lorsque ces cellules sont stressées ou endommagées. Comme elle est relativement spécifique du foie, c'est l'un des principaux marqueurs pour détecter une atteinte ou une inflammation hépatique. Les valeurs peuvent monter avec la stéatose hépatique, une hépatite virale, l'alcool ou certains médicaments. On la lit généralement avec l'ASAT et les autres marqueurs du bilan hépatique.",
|
||||
"ast": "L'ASAT (aspartate aminotransférase) est une enzyme présente dans le foie, mais aussi dans le muscle, le cœur et les globules rouges, libérée lorsque l'un de ces tissus est lésé. Comme elle est moins spécifique du foie que l'ALAT, les deux s'interprètent ensemble, et leur rapport peut orienter sur l'origine et la nature du problème. Un exercice intense ou une lésion musculaire peuvent l'élever indépendamment du foie. C'est l'une des enzymes standard du bilan hépatique.",
|
||||
"ggt": "La GGT (gamma-glutamyl transférase) est une enzyme du foie et des voies biliaires qui monte en particulier lorsque l'écoulement de la bile est entravé ou en cas d'exposition à l'alcool. Elle sert souvent à clarifier pourquoi un autre marqueur hépatique est élevé — par exemple, pour distinguer une origine hépatique ou biliaire. Elle est sensible mais peu spécifique ; on la lit donc dans le contexte du reste du bilan hépatique. C'est l'un des marqueurs utilisés pour évaluer la santé du foie et des voies biliaires.",
|
||||
"sodium": "Le sodium est le principal électrolyte du liquide situé à l'extérieur des cellules et le moteur essentiel de l'équilibre hydrique et du volume sanguin. Le corps le régule étroitement par la soif, les hormones et les reins, de sorte que son taux sanguin reflète davantage l'équilibre entre l'eau et le sel que le seul apport alimentaire. Des valeurs anormales signalent généralement un trouble de l'équilibre de l'eau plutôt que du sodium lui-même. C'est un élément central du bilan métabolique de base et des électrolytes.",
|
||||
"potassium": "Le potassium est le principal électrolyte à l'intérieur des cellules et il est essentiel à la transmission nerveuse et au rythme régulier du muscle cardiaque. Son taux sanguin est maintenu dans une plage étroite par les reins et les signaux hormonaux, car des valeurs hautes comme basses peuvent perturber l'activité électrique du cœur. Les taux peuvent être influencés par la fonction rénale, l'hydratation et plusieurs médicaments courants. C'est une mesure centrale du bilan des électrolytes.",
|
||||
"hemoglobin": "L'hémoglobine est la protéine contenant du fer des globules rouges, qui fixe l'oxygène dans les poumons et le transporte vers les tissus. Sa concentration est la principale mesure de la capacité du sang à transporter l'oxygène et le marqueur central pour détecter l'anémie. Des valeurs basses peuvent refléter une perte de sang, un manque de fer ou de vitamines, ou une production réduite, tandis que des valeurs élevées peuvent suivre une déshydratation ou des conditions pauvres en oxygène. C'est une pierre angulaire de la numération formule sanguine.",
|
||||
"hematocrit": "L'hématocrite est la proportion du volume sanguin constituée de globules rouges, exprimée en pourcentage. Il évolue étroitement avec l'hémoglobine et offre une vue complémentaire de la masse de globules rouges et de la capacité du sang à transporter l'oxygène. L'hydratation l'influence, car les variations du volume de plasma déplacent la proportion même lorsque le nombre de globules rouges reste stable. Il se lit avec l'hémoglobine et le reste de la numération sanguine.",
|
||||
"wbc": "La numération des globules blancs est le nombre total de cellules immunitaires circulant dans le sang, la défense mobile du corps contre les infections. Elle augmente en réponse à une infection, une inflammation ou un stress et peut baisser lorsque la moelle osseuse est freinée ou dans certaines maladies immunitaires. Comme elle additionne plusieurs types de cellules distincts, une variation notable est souvent complétée par une formule qui la détaille. C'est une composante centrale de la numération formule sanguine.",
|
||||
"platelets": "Les plaquettes sont de petits fragments cellulaires du sang qui s'agrègent et forment le bouchon initial arrêtant le saignement d'un vaisseau lésé. Leur nombre reflète l'équilibre entre la production dans la moelle osseuse et la consommation ou la destruction. Un nombre bas peut accroître la tendance au saignement, tandis qu'un nombre élevé peut suivre une inflammation ou d'autres stimuli. Elles font partie de la numération formule sanguine et constituent une jauge de base de la capacité de coagulation.",
|
||||
"apob": "L'apolipoprotéine B est la protéine structurale portée par chacune des particules de lipoprotéines athérogènes ; la doser revient donc à compter directement ces particules plutôt que le cholestérol qu'elles transportent. Comme il y a une molécule d'ApoB sur chaque particule de LDL, de VLDL et apparentée, elle peut capter un risque cardiovasculaire qu'une valeur de cholestérol LDL manque, en particulier lorsque le nombre de particules et leur teneur en cholestérol divergent. C'est pourquoi elle est de plus en plus utilisée pour affiner l'évaluation du risque fondée sur les lipides. Elle s'interprète avec le bilan lipidique habituel.",
|
||||
"lp-a": "La lipoprotéine(a) est une particule semblable au LDL avec une protéine supplémentaire attachée, dont le taux est en grande partie déterminé par la génétique et reste assez stable toute la vie. Une Lp(a) élevée est un facteur de risque indépendant et héréditaire de maladie cardiovasculaire et de rétrécissement de la valve aortique. Comme elle varie peu, on la mesure généralement une seule fois pour établir le risque à vie d'une personne, plutôt que de la suivre de façon répétée. Elle s'exprime en unités molaires et se lit dans le cadre du risque cardiovasculaire global.",
|
||||
"fasting-insulin": "L'insuline à jeun est le taux d'insuline — l'hormone qui fait passer le glucose du sang vers les cellules — mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.",
|
||||
"omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA — les deux acides gras oméga-3 marins à longue chaîne — parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois."
|
||||
},
|
||||
"apob": "Apolipoprotéine B (ApoB)",
|
||||
"lp-a": "Lipoprotéine(a)",
|
||||
@@ -7318,8 +7359,8 @@
|
||||
"description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Documents entrants",
|
||||
"description": "Importez un compte rendu médical ou une lettre de sortie ; vérifiez les données extraites avant de les enregistrer. Désactivé par défaut."
|
||||
"label": "Documents",
|
||||
"description": "Conservez vos documents médicaux ; extrayez et vérifiez éventuellement leurs éléments avant l'enregistrement. Désactivé par défaut."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Bien-être mental",
|
||||
@@ -7558,8 +7599,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Point sur le bien-être mental",
|
||||
"pageDescription": "Deux auto-évaluations validées et facultatives — PHQ-9 (humeur) et GAD-7 (inquiétude) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
|
||||
"disclaimer": "Il s'agit d'une auto-évaluation de dépistage, pas d'un diagnostic. Un score élevé ou bas ne signifie pas que tu as ou n'as pas un trouble. Seul un professionnel de santé qualifié peut poser un diagnostic. En cas de doute, parles-en à ton médecin.",
|
||||
"optInNote": "Facultatif et privé : tes réponses individuelles sont chiffrées, tenues à l'écart du Coach IA et des exports, et ne sont jamais partagées.",
|
||||
"infoTooltip": "Il s'agit d'un autotest de dépistage, pas d'un diagnostic.",
|
||||
"attributionLabel": "Source",
|
||||
"choosePrompt": "Choisis un bilan",
|
||||
"start": "Commencer",
|
||||
@@ -7774,6 +7814,15 @@
|
||||
"updatedToast": "Entrée mise à jour",
|
||||
"saveError": "Impossible d'enregistrer l'entrée."
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Affections préexistantes",
|
||||
"cardDescription": "Affections chroniques et ce que le Coach doit surveiller, d'après votre contexte personnel.",
|
||||
"conditionsLabel": "Affections chroniques",
|
||||
"focusLabel": "Ce que le Coach surveille",
|
||||
"empty": "Rien de noté pour l'instant.",
|
||||
"editCta": "Modifier dans le contexte personnel",
|
||||
"loadError": "Impossible de charger vos antécédents."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -7788,37 +7837,50 @@
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents entrants",
|
||||
"subtitle": "Importez un document clinique et vérifiez les données que nous y avons lues.",
|
||||
"disclaimer": {
|
||||
"title": "Nous lisons, nous n'interprétons pas",
|
||||
"body": "Nous transcrivons uniquement ce qu'indique votre document — valeurs, codes et dates — et nous ne posons aucun diagnostic, n'interprétons et ne signalons rien. Rien n'est enregistré tant que vous ne l'avez pas vérifié et approuvé. Seul votre professionnel de santé peut interpréter ces résultats."
|
||||
},
|
||||
"title": "Documents",
|
||||
"subtitle": "Conservez vos documents médicaux et vérifiez ce qu'ils contiennent.",
|
||||
"upload": {
|
||||
"label": "Importer un document",
|
||||
"kindLabel": "Type de document",
|
||||
"button": "Importer",
|
||||
"uploading": "Lecture…",
|
||||
"fileLabel": "Fichier du document"
|
||||
"label": "Ajouter un document",
|
||||
"kindLabel": "Catégorie",
|
||||
"button": "Téléverser",
|
||||
"uploading": "Téléversement…",
|
||||
"fileLabel": "Fichier (requis)",
|
||||
"titleLabel": "Titre (facultatif)",
|
||||
"titlePlaceholder": "p. ex. Compte rendu de cardiologie",
|
||||
"dateLabel": "Date du document (facultatif)",
|
||||
"fileRequired": "Choisissez un fichier à téléverser.",
|
||||
"accepts": "JPEG, PNG, WebP ou PDF · jusqu'à 12 Mo"
|
||||
},
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Compte rendu médical",
|
||||
"DISCHARGE_LETTER": "Lettre de sortie",
|
||||
"OTHER": "Autre"
|
||||
"OTHER": "Autre",
|
||||
"LAB_RESULT": "Résultat de laboratoire",
|
||||
"IMAGING": "Imagerie",
|
||||
"PRESCRIPTION": "Ordonnance",
|
||||
"INSURANCE": "Assurance",
|
||||
"VACCINATION": "Vaccination"
|
||||
},
|
||||
"list": {
|
||||
"empty": "Aucun document pour l'instant."
|
||||
"empty": "Aucun document pour l'instant.",
|
||||
"noResults": "Aucun document ne correspond à vos filtres.",
|
||||
"loadMore": "Charger plus",
|
||||
"loadingMore": "Chargement…",
|
||||
"pending": "{count} à vérifier",
|
||||
"facts": "{count} éléments",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"loadError": "Impossible de charger vos documents."
|
||||
},
|
||||
"status": {
|
||||
"EXTRACTING": "Lecture",
|
||||
"EXTRACTED": "Prêt à vérifier",
|
||||
"FAILED": "Lecture impossible",
|
||||
"CONFIRMED": "Vérifié",
|
||||
"DISCARDED": "Écarté"
|
||||
"DISCARDED": "Écarté",
|
||||
"STORED": "Conservé"
|
||||
},
|
||||
"review": {
|
||||
"title": "Vérifier les données extraites",
|
||||
"discard": "Écarter",
|
||||
"confirm": "Enregistrer les données approuvées",
|
||||
"confirming": "Enregistrement…",
|
||||
"needsReview": "À vérifier",
|
||||
@@ -7826,7 +7888,14 @@
|
||||
"reject": "Rejeter",
|
||||
"edit": "Modifier",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer"
|
||||
"save": "Enregistrer",
|
||||
"needsReviewHint": "Modifie cette donnée pour pouvoir la valider.",
|
||||
"confidenceLabel": "Confiance d'extraction",
|
||||
"commitError": {
|
||||
"unitMismatch": "l'unité ne correspond pas à celle enregistrée",
|
||||
"unitRequired": "une valeur numérique nécessite une unité",
|
||||
"generic": "il n'a pas pu être enregistré"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noFacts": "Plus rien à vérifier."
|
||||
@@ -7844,21 +7913,72 @@
|
||||
"dose": "Dose"
|
||||
},
|
||||
"toast": {
|
||||
"uploaded": "Document lu. Vérifiez les données ci-dessous.",
|
||||
"uploadFailed": "Lecture du document impossible.",
|
||||
"uploaded": "Document enregistré.",
|
||||
"uploadFailed": "Impossible d'enregistrer le document.",
|
||||
"confirmed": "Données approuvées enregistrées.",
|
||||
"confirmedPartial": "Enregistré, mais {count} n'ont pas pu être enregistrés — {reason}.",
|
||||
"confirmFailed": "Impossible d'enregistrer vos décisions.",
|
||||
"saved": "Donnée mise à jour.",
|
||||
"discarded": "Document écarté."
|
||||
"discarded": "Document écarté.",
|
||||
"extracted": "Éléments extraits. Vérifiez-les ci-dessous.",
|
||||
"extractFailed": "Impossible d'extraire les éléments.",
|
||||
"localOcrDisabled": "Activez l'OCR local dans les paramètres pour lire ce document sur l'appareil.",
|
||||
"updated": "Document mis à jour.",
|
||||
"updateFailed": "Impossible de mettre à jour le document.",
|
||||
"discardFailed": "Impossible de supprimer le document.",
|
||||
"uploadTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
|
||||
"uploadFileType": "Importe un fichier JPEG, PNG, WebP ou PDF.",
|
||||
"uploadRateLimited": "Trop d'envois. Réessaie plus tard.",
|
||||
"uploadInvalidMetadata": "Vérifie le titre, la catégorie ou la date, puis réessaie."
|
||||
},
|
||||
"toolbar": {
|
||||
"searchLabel": "Rechercher",
|
||||
"searchPlaceholder": "Titre ou nom de fichier",
|
||||
"kindFilterLabel": "Catégorie",
|
||||
"allKinds": "Toutes les catégories",
|
||||
"sortLabel": "Trier par",
|
||||
"sort": {
|
||||
"documentDate": "Date du document",
|
||||
"createdAt": "Date d'ajout",
|
||||
"title": "Titre"
|
||||
},
|
||||
"orderAsc": "Croissant",
|
||||
"orderDesc": "Décroissant",
|
||||
"fromLabel": "Du",
|
||||
"toLabel": "Au"
|
||||
},
|
||||
"detail": {
|
||||
"viewOriginal": "Voir l'original",
|
||||
"edit": "Modifier",
|
||||
"extract": "Extraire les éléments",
|
||||
"extracting": "Extraction…",
|
||||
"discard": "Écarter",
|
||||
"notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document.",
|
||||
"viewOriginalNewTab": "(s'ouvre dans un nouvel onglet)",
|
||||
"discardConfirmTitle": "Supprimer ce document ?",
|
||||
"discardConfirmBody": "Cela supprime définitivement le document, son fichier d'origine et les données préparées. Cette action est irréversible.",
|
||||
"discardConfirmAction": "Supprimer",
|
||||
"discardCancel": "Annuler"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le document",
|
||||
"subtitle": "Renommez, recatégorisez ou définissez la date du document.",
|
||||
"titleLabel": "Titre",
|
||||
"kindLabel": "Catégorie",
|
||||
"dateLabel": "Date du document",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement…",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"note": {
|
||||
"providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.",
|
||||
"alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau."
|
||||
},
|
||||
"committed": {
|
||||
"title": "Validé depuis ce document"
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog est un outil personnel de suivi de santé et de bien-être. Il vous aide à enregistrer, visualiser et exporter vos propres données de santé. Il ne diagnostique, ne traite, ne guérit ni ne prévient aucune maladie, et n'est pas un dispositif médical.",
|
||||
"coach": "Le Coach s'appuie sur vos propres données enregistrées. Ce n'est pas un médecin et il ne donne pas de conseil médical : pour un diagnostic ou un traitement, consultez un professionnel de santé.",
|
||||
"insights": "Ce sont des tendances descriptives calculées à partir de vos propres données enregistrées, pas une évaluation clinique ni un diagnostic.",
|
||||
"labs": "Ce sont vos propres relevés, pas un diagnostic médical. Les valeurs de référence varient selon le laboratoire et la personne : seul votre médecin peut les interpréter pour vous.",
|
||||
"medications": "Ceci suit les médicaments que vous enregistrez et votre propre observance. Cela n'évalue ni les doses ni les interactions et n'est pas un conseil médical : confirmez-les avec votre pharmacien ou votre médecin.",
|
||||
"emergency": "Si cela pourrait être une urgence, contactez maintenant votre numéro d'urgence local ou un professionnel de santé. HealthLog ne peut pas aider en cas d'urgence.",
|
||||
"dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé."
|
||||
}
|
||||
}
|
||||
+190
-70
@@ -4018,16 +4018,46 @@
|
||||
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sezioni delle impostazioni"
|
||||
"sectionsNav": "Sezioni delle impostazioni",
|
||||
"subSectionsNav": "Sottosezioni delle impostazioni"
|
||||
},
|
||||
"groups": {
|
||||
"account": {
|
||||
"title": "Account"
|
||||
},
|
||||
"tracking": {
|
||||
"title": "Monitoraggio"
|
||||
},
|
||||
"display": {
|
||||
"title": "Visualizzazione"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrazioni"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche"
|
||||
},
|
||||
"ai": {
|
||||
"title": "IA e coach"
|
||||
},
|
||||
"access": {
|
||||
"title": "API e accesso"
|
||||
},
|
||||
"data": {
|
||||
"title": "Dati e privacy"
|
||||
},
|
||||
"about": {
|
||||
"title": "Informazioni"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"title": "Profilo e preferenze",
|
||||
"description": "Profilo, password, passkey e il tour di benvenuto.",
|
||||
"subtitle": "Il tuo profilo, la tua identità e i dati di accesso."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrazioni",
|
||||
"title": "Connessioni",
|
||||
"description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
|
||||
"subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi."
|
||||
},
|
||||
@@ -4036,11 +4066,10 @@
|
||||
"description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
|
||||
"channelsHintLink": "Integrazioni → Canali",
|
||||
"inboxLink": "Apri la posta in arrivo",
|
||||
"subtitle": "Scegli quali promemoria ricevere."
|
||||
},
|
||||
"layout": {
|
||||
"title": "Cruscotto",
|
||||
"title": "Visualizzazione",
|
||||
"description": "Organizza la disposizione del cruscotto e degli approfondimenti.",
|
||||
"backToHub": "Torna a Layout e personalizzazione",
|
||||
"dashboard": {
|
||||
@@ -4319,9 +4348,9 @@
|
||||
"subtitle": "Autenticazione a due fattori, codici di recupero e passkey."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Dati e privacy",
|
||||
"subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.",
|
||||
"description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza."
|
||||
"title": "Privacy e conservazione",
|
||||
"subtitle": "I tuoi dati, la conservazione e la cifratura in un unico posto.",
|
||||
"description": "Esportazione, eliminazione, conservazione e stato della cifratura."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Connettore MCP",
|
||||
@@ -4665,7 +4694,9 @@
|
||||
"focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…",
|
||||
"completeness": "{answered} su {total} compilati",
|
||||
"questionsTitle": "Il Coach ha alcune domande",
|
||||
"questionsOpenCoach": "Rispondi nel Coach"
|
||||
"questionsOpenCoach": "Rispondi nel Coach",
|
||||
"recordsLink": "Allergie, anamnesi familiare e le tue patologie preesistenti fanno parte della tua anamnesi.",
|
||||
"recordsLinkCta": "Apri anamnesi"
|
||||
},
|
||||
"responseTimeoutHeading": "Timeout della risposta",
|
||||
"responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.",
|
||||
@@ -5286,6 +5317,11 @@
|
||||
"description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.",
|
||||
"openAi": "Modalità privacy IA",
|
||||
"openResearch": "Modalità ricerca"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Accesso e dispositivi",
|
||||
"description": "Le sessioni attive, i dispositivi attendibili e l'attività di accesso si trovano ora nelle impostazioni di sicurezza del tuo account.",
|
||||
"openSecurity": "Gestisci accesso e sicurezza"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -7061,9 +7097,14 @@
|
||||
"restoredToast": "Misurazione ripristinata",
|
||||
"restoreError": "Impossibile ripristinare la misurazione",
|
||||
"detail": {
|
||||
"history": "Cronologia",
|
||||
"emptyTitle": "Ancora nessuna misurazione",
|
||||
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo."
|
||||
"emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo.",
|
||||
"title": "Biomarcatore",
|
||||
"genericDescription": "Un biomarcatore di laboratorio che monitori. Aggiungi misurazioni per seguirne l'andamento rispetto all'intervallo di riferimento.",
|
||||
"valuesTitle": "{marker} — tutte le letture",
|
||||
"valuesDescription": "Ogni lettura di questo biomarcatore. Modifica o rimuovi una voce direttamente.",
|
||||
"backToMarker": "Torna a {marker}",
|
||||
"latestResult": "Ultimo risultato"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Nessuna misurazione in questo periodo.",
|
||||
@@ -7172,36 +7213,36 @@
|
||||
"bloodCount": "Emocromo"
|
||||
},
|
||||
"desc": {
|
||||
"total-cholesterol": "Quantità totale di colesterolo trasportato nel sangue, somma delle frazioni LDL, HDL e altre lipoproteine.",
|
||||
"ldl": "Colesterolo trasportato dalle lipoproteine a bassa densità, la frazione che si deposita nelle pareti arteriose.",
|
||||
"hdl": "Colesterolo trasportato dalle lipoproteine ad alta densità, che lo riportano al fegato.",
|
||||
"triglycerides": "Il principale grasso circolante nel sangue, assunto con la dieta e prodotto dal fegato.",
|
||||
"fasting-glucose": "Glicemia misurata a digiuno, riflette la regolazione di base del glucosio.",
|
||||
"hba1c": "Quota di emoglobina legata al glucosio, riflette la glicemia degli ultimi due o tre mesi.",
|
||||
"tsh": "Ormone ipofisario che regola la tiroide; il principale test della funzione tiroidea.",
|
||||
"ft3": "La forma libera e attiva della triiodotironina, l'ormone tiroideo più potente.",
|
||||
"ft4": "La forma libera della tiroxina, il principale ormone che la tiroide rilascia nel sangue.",
|
||||
"ferritin": "La principale proteina di deposito del ferro, riflette le riserve di ferro.",
|
||||
"transferrin-saturation": "Quota della transferrina, proteina di trasporto del ferro, che trasporta ferro in quel momento.",
|
||||
"vitamin-d": "25-idrossivitamina D circolante, il marcatore standard dello stato di vitamina D.",
|
||||
"vitamin-b12": "Vitamina essenziale per la formazione dei globuli rossi e la funzione nervosa.",
|
||||
"folate": "Vitamina B necessaria per la formazione dei globuli rossi e la sintesi del DNA.",
|
||||
"hs-crp": "Una proteina epatica che aumenta con l'infiammazione, misurata ad alta sensibilità.",
|
||||
"creatinine": "Prodotto di scarto muscolare eliminato dai reni, usato per valutare la funzione renale.",
|
||||
"egfr": "Velocità di filtrazione stimata dei reni, calcolata a partire dalla creatinina.",
|
||||
"alt": "Enzima epatico rilasciato nel sangue quando le cellule del fegato sono danneggiate.",
|
||||
"ast": "Enzima di fegato e muscolo che aumenta quando questi tessuti sono lesi.",
|
||||
"ggt": "Enzima di fegato e vie biliari che aumenta con l'ostruzione biliare o l'alcol.",
|
||||
"sodium": "Principale elettrolita che regola l'equilibrio dei liquidi e il volume del sangue.",
|
||||
"potassium": "Elettrolita essenziale per la conduzione nervosa e la funzione del muscolo cardiaco.",
|
||||
"hemoglobin": "La proteina dei globuli rossi che trasporta l'ossigeno.",
|
||||
"hematocrit": "La frazione del volume sanguigno costituita da globuli rossi.",
|
||||
"wbc": "Conteggio totale dei globuli bianchi, la difesa circolante del sistema immunitario.",
|
||||
"platelets": "Frammenti cellulari che si aggregano per formare coaguli di sangue.",
|
||||
"apob": "Conta direttamente le particelle aterogene; spesso un marcatore di rischio migliore del LDL-C quando i due discordano.",
|
||||
"lp-a": "Fattore di rischio in gran parte ereditario e permanente — di solito misurato una volta. Riportare in unità molari (nmol/L).",
|
||||
"fasting-insulin": "Si abbina alla glicemia a digiuno per stimare la resistenza insulinica (HOMA-IR).",
|
||||
"omega-3-index": "Quota di EPA+DHA nei globuli rossi; 8–12% è l'intervallo cardioprotettivo comunemente citato."
|
||||
"total-cholesterol": "Il colesterolo totale è la somma del colesterolo trasportato da tutte le particelle di lipoproteine del sangue, comprese le frazioni LDL, HDL e i residui. Il colesterolo è di per sé un componente essenziale delle membrane cellulari, degli ormoni e degli acidi biliari. Come valore singolo resta una misura grossolana, perché sia una quota elevata di HDL sia una di LDL fanno salire il totale: per questo si legge di solito insieme alle singole frazioni. Viene rilevato soprattutto nell'ambito della valutazione del rischio cardiovascolare a lungo termine.",
|
||||
"ldl": "Il colesterolo LDL è il colesterolo trasportato dalle particelle di lipoproteine a bassa densità, la frazione che porta il colesterolo ai tessuti ma può anche depositarlo nelle pareti delle arterie. Negli anni questo deposito contribuisce alla placca aterosclerotica, motivo per cui l'LDL è uno dei marcatori lipidici più sorvegliati. Una minore esposizione a lungo termine si accompagna in genere a un rischio cardiovascolare più basso. Si interpreta per categoria di rischio e non rispetto a un unico valore normale fisso.",
|
||||
"hdl": "Il colesterolo HDL è il colesterolo trasportato dalle particelle di lipoproteine ad alta densità, che raccolgono il colesterolo in eccesso dai tessuti e lo riportano al fegato per riciclarlo o eliminarlo. Per questo ruolo di trasporto viene spesso definito la frazione protettiva. Valori molto bassi tendono ad accompagnare un rischio cardiovascolare più elevato, ma aumentarlo artificialmente non ha ridotto il rischio in modo affidabile: per questo si legge come marcatore e non come obiettivo. Di solito si interpreta insieme a LDL e trigliceridi.",
|
||||
"triglycerides": "I trigliceridi sono la principale forma di grasso che circola nel sangue, fornita sia dalla dieta sia prodotta dal fegato, e rappresentano una riserva chiave di energia. I valori salgono dopo i pasti, perciò il prelievo si esegue di solito a digiuno per renderlo confrontabile. Trigliceridi costantemente elevati sono associati a rischio metabolico e cardiovascolare e spesso accompagnano la resistenza all'insulina e un HDL basso. Si leggono come una componente del quadro lipidico complessivo.",
|
||||
"fasting-glucose": "La glicemia a digiuno è la concentrazione di zucchero nel sangue misurata dopo un digiuno notturno e riflette come il corpo mantiene il proprio livello di base senza cibo recente. Il glucosio è il principale carburante immediato dell'organismo e l'insulina lo mantiene normalmente entro un intervallo ristretto. Una misurazione a digiuno elimina l'effetto dei pasti recenti, rendendola uno screening standard del funzionamento della regolazione del glucosio. È uno dei marcatori con cui si valuta la salute metabolica nel tempo.",
|
||||
"hba1c": "L'HbA1c è la quota di emoglobina dei globuli rossi che si è legata al glucosio e si accumula in proporzione all'esposizione media allo zucchero nel sangue. Poiché i globuli rossi vivono circa tre mesi, riflette la glicemia media dei due o tre mesi precedenti anziché un singolo momento. Questo la rende una visione stabile e indipendente dai pasti del controllo della glicemia. La sua attendibilità può essere influenzata da condizioni che alterano la durata di vita dei globuli rossi.",
|
||||
"tsh": "Il TSH (ormone tireostimolante) viene rilasciato dall'ipofisi per segnalare alla tiroide di produrre i propri ormoni e si regola al rialzo o al ribasso per mantenerli in equilibrio. Grazie a questo circuito di retroazione, il TSH è lo screening di prima linea più sensibile della funzione tiroidea: tende a muoversi prima che gli ormoni tiroidei stessi si discostino. Un valore alto o basso indica una tiroide ipo o iperattiva e di solito porta a misurare direttamente gli ormoni tiroidei. La gravidanza e l'età spostano l'intervallo atteso.",
|
||||
"ft3": "Il T3 libero (triiodotironina) è la forma non legata e biologicamente attiva dell'ormone tiroideo più potente, la frazione disponibile ad agire sui tessuti. La maggior parte del T3 circolante è legata a proteine di trasporto e inattiva, perciò la quota libera riflette la reale disponibilità dell'ormone. Si misura per caratterizzare la funzione tiroidea, soprattutto insieme a TSH e T4 libero quando i risultati non sono chiari. I valori dipendono molto dal metodo di analisi e si leggono nel loro contesto clinico.",
|
||||
"ft4": "Il T4 libero (tiroxina) è la forma non legata del principale ormone che la tiroide rilascia nel sangue e funge da riserva che i tessuti convertono nel T3 più attivo secondo necessità. La frazione libera riflette ciò che è realmente disponibile per il corpo, indipendentemente dal livello delle proteine di trasporto. Di solito si misura insieme al TSH per distinguere i problemi della tiroide da quelli della segnalazione ipofisaria. La gravidanza e alcuni farmaci possono spostarlo.",
|
||||
"ferritin": "La ferritina è la principale proteina di deposito del ferro dell'organismo e il suo valore nel sangue rispecchia a grandi linee le riserve totali di ferro. Un valore basso indica riserve esaurite, mentre uno alto può riflettere un sovraccarico di ferro, oppure semplicemente un'infiammazione, perché la ferritina sale anche come reattante della fase acuta. Per questo si interpreta spesso insieme ad altri marcatori del ferro e a segni di infiammazione. È una delle misure centrali per valutare lo stato del ferro.",
|
||||
"transferrin-saturation": "La saturazione della transferrina è la percentuale di transferrina — la proteina che trasporta il ferro nel sangue — attualmente carica di ferro. Collega la quantità di ferro in transito alla capacità di trasporto dell'organismo, offrendo un'istantanea della disponibilità di ferro che completa la visione delle riserve data dalla ferritina. Una saturazione bassa suggerisce un ferro scarso, mentre un valore alto può indicare un sovraccarico. Si legge insieme alla ferritina e all'assetto marziale completo.",
|
||||
"vitamin-d": "La vitamina D si misura come 25-idrossivitamina D, la forma di deposito circolante e il marcatore standard dello stato complessivo di vitamina D. Il corpo la produce nella pelle esposta alla luce solare e ne assorbe una parte dagli alimenti; sostiene l'equilibrio del calcio, la salute delle ossa e la funzione immunitaria. I valori variano con la stagione, la latitudine, il tono della pelle e l'integrazione. Viene rilevata per stimare nel tempo se le riserve sono adeguate.",
|
||||
"vitamin-b12": "La vitamina B12 (cobalamina) è una vitamina essenziale, ottenuta soprattutto da alimenti di origine animale e immagazzinata in gran parte nel fegato. È necessaria per la formazione dei globuli rossi, la sintesi del DNA e la salute del sistema nervoso. Una carenza prolungata — da scarso apporto o assorbimento alterato — può portare ad anemia e a sintomi neurologici. Si misura per valutare lo stato nutrizionale, in particolare quando apporto o assorbimento possono essere limitati.",
|
||||
"folate": "Il folato (vitamina B9) è una vitamina del gruppo B presente nelle verdure a foglia verde, nei legumi e negli alimenti fortificati, centrale per la formazione dei globuli rossi e la sintesi del DNA. Lavora a stretto contatto con la vitamina B12 e una carenza dell'uno o dell'altra può produrre un tipo simile di anemia. Un folato adeguato è particolarmente importante intorno all'inizio della gravidanza per uno sviluppo sano del feto. Si misura per valutare lo stato nutrizionale e la causa di alcune anemie.",
|
||||
"hs-crp": "La PCR ad alta sensibilità misura la proteina C-reattiva, un marcatore che il fegato rilascia in risposta all'infiammazione, con una tecnica affinata per rilevare le basse concentrazioni rilevanti per il rischio cardiovascolare. A differenza della PCR standard usata per le infezioni acute, la versione ad alta sensibilità coglie l'infiammazione lieve e cronica legata alla malattia vascolare. Poiché qualsiasi infezione o lesione recente può aumentarla, si interpreta lontano da una malattia acuta e spesso si conferma con un prelievo ripetuto. Si legge come uno dei fattori del rischio cardiovascolare complessivo.",
|
||||
"creatinine": "La creatinina è un prodotto di scarto del normale metabolismo muscolare che i reni filtrano dal sangue a un ritmo abbastanza costante. Poiché la produzione è relativamente stabile, il suo valore nel sangue funge da misura pratica di quanto bene i reni eliminano gli scarti. I valori dipendono anche dalla massa muscolare, dall'idratazione e dalla dieta, perciò si interpretano di solito insieme alla velocità di filtrazione stimata. È un marcatore centrale della funzione renale.",
|
||||
"egfr": "L'eGFR (velocità di filtrazione glomerulare stimata) stima quanto sangue i reni filtrano ogni minuto, calcolata dalla creatinina insieme a età e sesso. Traduce un valore grezzo di creatinina in una misura della funzione renale più immediatamente interpretabile. Un eGFR in calo nel tempo è il modo principale per classificare e seguire le alterazioni renali croniche. Essendo una stima, si legge come una tendenza e non come un'unica cifra esatta.",
|
||||
"alt": "L'ALT (alanina aminotransferasi) è un enzima concentrato nelle cellule del fegato, rilasciato nel sangue quando queste cellule sono sotto stress o danneggiate. Essendo relativamente specifico del fegato, è uno dei principali marcatori per rilevare un danno o un'infiammazione epatica. I valori possono salire con il fegato grasso, l'epatite virale, l'alcol o alcuni farmaci. Di solito si legge insieme all'AST e agli altri marcatori del profilo epatico.",
|
||||
"ast": "L'AST (aspartato aminotransferasi) è un enzima presente nel fegato ma anche nel muscolo, nel cuore e nei globuli rossi, rilasciato quando uno di questi tessuti subisce un danno. Essendo meno specifico del fegato rispetto all'ALT, i due si interpretano insieme e il loro rapporto può orientare sull'origine e sulla natura del problema. Un esercizio intenso o una lesione muscolare possono aumentarlo indipendentemente dal fegato. È uno degli enzimi standard del profilo epatico.",
|
||||
"ggt": "La GGT (gamma-glutamil transferasi) è un enzima del fegato e delle vie biliari che sale in particolare quando il flusso della bile è ostruito o in caso di esposizione all'alcol. Spesso si usa per chiarire perché un altro marcatore epatico è elevato — per esempio, per distinguere un'origine epatica o biliare. È sensibile ma poco specifica, perciò si legge nel contesto del resto del profilo epatico. È uno dei vari marcatori usati per valutare la salute del fegato e delle vie biliari.",
|
||||
"sodium": "Il sodio è il principale elettrolita del liquido al di fuori delle cellule del corpo e il motore fondamentale dell'equilibrio dei liquidi e del volume del sangue. L'organismo lo regola in modo stretto tramite la sete, gli ormoni e i reni, perciò il suo valore nel sangue riflette più l'equilibrio tra acqua e sale che il solo apporto alimentare. Valori anomali segnalano di solito un disturbo dell'equilibrio idrico più che del sodio stesso. È una parte centrale del pannello metabolico di base e degli elettroliti.",
|
||||
"potassium": "Il potassio è il principale elettrolita all'interno delle cellule del corpo ed è essenziale per la trasmissione nervosa e per il ritmo regolare del muscolo cardiaco. Il suo valore nel sangue è mantenuto entro un intervallo ristretto dai reni e dai segnali ormonali, perché sia i valori alti sia quelli bassi possono disturbare l'attività elettrica del cuore. I valori possono essere influenzati dalla funzione renale, dall'idratazione e da diversi farmaci comuni. È una misura centrale del pannello degli elettroliti.",
|
||||
"hemoglobin": "L'emoglobina è la proteina contenente ferro dei globuli rossi che lega l'ossigeno nei polmoni e lo trasporta ai tessuti. La sua concentrazione è la principale misura della capacità del sangue di trasportare ossigeno e il marcatore centrale per rilevare l'anemia. Valori bassi possono riflettere perdita di sangue, carenza di ferro o di vitamine, o una produzione ridotta, mentre valori alti possono seguire una disidratazione o condizioni di scarso ossigeno. È un pilastro dell'emocromo completo.",
|
||||
"hematocrit": "L'ematocrito è la proporzione del volume del sangue costituita dai globuli rossi, espressa in percentuale. Si muove a stretto contatto con l'emoglobina e offre una visione complementare della massa dei globuli rossi e della capacità del sangue di trasportare ossigeno. L'idratazione lo influenza, perché le variazioni del volume del plasma spostano la proporzione anche quando il numero dei globuli rossi resta stabile. Si legge insieme all'emoglobina e al resto dell'emocromo.",
|
||||
"wbc": "La conta dei globuli bianchi è il numero totale di cellule immunitarie che circolano nel sangue, la difesa mobile del corpo contro le infezioni. Sale in risposta a infezione, infiammazione o stress e può calare quando il midollo osseo è soppresso o in alcune malattie immunitarie. Poiché somma diversi tipi di cellule, una variazione marcata è spesso completata da una formula che la scompone. È una componente centrale dell'emocromo completo.",
|
||||
"platelets": "Le piastrine sono piccoli frammenti cellulari del sangue che si aggregano e formano il tappo iniziale che arresta il sanguinamento da un vaso danneggiato. Il loro numero riflette l'equilibrio tra la produzione nel midollo osseo e il consumo o la distruzione. Un numero basso può aumentare la tendenza al sanguinamento, mentre uno alto può seguire un'infiammazione o altri stimoli. Fanno parte dell'emocromo completo e sono una misura di base della capacità di coagulazione.",
|
||||
"apob": "L'apolipoproteina B è la proteina strutturale presente su ciascuna delle particelle di lipoproteine aterogene, quindi misurarla significa contare direttamente quelle particelle anziché il colesterolo che trasportano. Poiché c'è una molecola di ApoB su ogni particella di LDL, VLDL e affini, può cogliere un rischio cardiovascolare che un valore di colesterolo LDL non rileva, soprattutto quando il numero di particelle e il loro contenuto di colesterolo divergono. Per questo è sempre più usata per affinare la valutazione del rischio basata sui lipidi. Si interpreta insieme al consueto profilo lipidico.",
|
||||
"lp-a": "La lipoproteina(a) è una particella simile all'LDL con una proteina aggiuntiva attaccata, il cui livello è in gran parte determinato dalla genetica e resta piuttosto stabile per tutta la vita. Una Lp(a) elevata è un fattore di rischio indipendente ed ereditario di malattia cardiovascolare e di restringimento della valvola aortica. Poiché varia poco, di solito si misura una sola volta per stabilire il rischio di una persona lungo l'arco della vita, anziché seguirla ripetutamente. Si esprime in unità molari e si legge nell'ambito del rischio cardiovascolare complessivo.",
|
||||
"fasting-insulin": "L'insulina a digiuno è il livello di insulina — l'ormone che fa passare il glucosio dal sangue alle cellule — misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.",
|
||||
"omega-3-index": "L'indice omega-3 è la quota di EPA e DHA — i due acidi grassi omega-3 marini a catena lunga — tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
@@ -7318,8 +7359,8 @@
|
||||
"description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Documenti in entrata",
|
||||
"description": "Carica un referto medico o una lettera di dimissione; rivedi i dati estratti prima di salvarli. Disattivato per impostazione predefinita."
|
||||
"label": "Documenti",
|
||||
"description": "Conserva i tuoi documenti clinici; facoltativamente estrai e rivedi i loro dati prima del salvataggio. Disattivato di default."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Benessere mentale",
|
||||
@@ -7558,8 +7599,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Check-in sul benessere mentale",
|
||||
"pageDescription": "Due autovalutazioni validate e facoltative — PHQ-9 (umore) e GAD-7 (preoccupazione) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
|
||||
"disclaimer": "Questa è un'autovalutazione di screening, non una diagnosi. Un punteggio alto o basso non significa che tu abbia o non abbia un disturbo. Solo un professionista sanitario qualificato può fare una diagnosi. In caso di dubbi, parlane con il tuo medico.",
|
||||
"optInNote": "Facoltativo e privato: le tue risposte individuali sono cifrate, tenute fuori dal Coach IA e dalle esportazioni, e non vengono mai condivise.",
|
||||
"infoTooltip": "Questo è un autotest di screening, non una diagnosi.",
|
||||
"attributionLabel": "Fonte",
|
||||
"choosePrompt": "Scegli un check-in",
|
||||
"start": "Inizia",
|
||||
@@ -7774,6 +7814,15 @@
|
||||
"updatedToast": "Voce aggiornata",
|
||||
"saveError": "Impossibile salvare la voce."
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Patologie preesistenti",
|
||||
"cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio, dal tuo contesto personale.",
|
||||
"conditionsLabel": "Patologie croniche",
|
||||
"focusLabel": "Cosa tiene d'occhio il Coach",
|
||||
"empty": "Ancora nessuna annotazione.",
|
||||
"editCta": "Modifica nel contesto personale",
|
||||
"loadError": "Impossibile caricare le tue patologie."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -7788,37 +7837,50 @@
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documenti in entrata",
|
||||
"subtitle": "Carica un documento clinico e rivedi i dati che abbiamo letto.",
|
||||
"disclaimer": {
|
||||
"title": "Leggiamo, non interpretiamo",
|
||||
"body": "Trascriviamo solo ciò che il documento indica — valori, codici e date — e non formuliamo diagnosi, non interpretiamo e non segnaliamo nulla. Niente viene salvato finché non lo verifichi e lo approvi. Solo il tuo medico può interpretare questi risultati."
|
||||
},
|
||||
"title": "Documenti",
|
||||
"subtitle": "Conserva i tuoi documenti clinici e controlla cosa contengono.",
|
||||
"upload": {
|
||||
"label": "Carica un documento",
|
||||
"kindLabel": "Tipo di documento",
|
||||
"label": "Aggiungi un documento",
|
||||
"kindLabel": "Categoria",
|
||||
"button": "Carica",
|
||||
"uploading": "Lettura…",
|
||||
"fileLabel": "File del documento"
|
||||
"uploading": "Caricamento…",
|
||||
"fileLabel": "File (obbligatorio)",
|
||||
"titleLabel": "Titolo (facoltativo)",
|
||||
"titlePlaceholder": "es. Referto di cardiologia",
|
||||
"dateLabel": "Data del documento (facoltativa)",
|
||||
"fileRequired": "Scegli un file da caricare.",
|
||||
"accepts": "JPEG, PNG, WebP o PDF · fino a 12 MB"
|
||||
},
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Referto medico",
|
||||
"DISCHARGE_LETTER": "Lettera di dimissione",
|
||||
"OTHER": "Altro"
|
||||
"OTHER": "Altro",
|
||||
"LAB_RESULT": "Risultato di laboratorio",
|
||||
"IMAGING": "Diagnostica per immagini",
|
||||
"PRESCRIPTION": "Prescrizione",
|
||||
"INSURANCE": "Assicurazione",
|
||||
"VACCINATION": "Vaccinazione"
|
||||
},
|
||||
"list": {
|
||||
"empty": "Ancora nessun documento."
|
||||
"empty": "Nessun documento ancora.",
|
||||
"noResults": "Nessun documento corrisponde ai filtri.",
|
||||
"loadMore": "Carica altri",
|
||||
"loadingMore": "Caricamento…",
|
||||
"pending": "{count} da rivedere",
|
||||
"facts": "{count} dati",
|
||||
"clearFilters": "Cancella i filtri",
|
||||
"loadError": "Impossibile caricare i tuoi documenti."
|
||||
},
|
||||
"status": {
|
||||
"EXTRACTING": "Lettura",
|
||||
"EXTRACTED": "Pronto per la revisione",
|
||||
"FAILED": "Impossibile leggere",
|
||||
"FAILED": "Lettura non riuscita",
|
||||
"CONFIRMED": "Rivisto",
|
||||
"DISCARDED": "Scartato"
|
||||
"DISCARDED": "Scartato",
|
||||
"STORED": "Salvato"
|
||||
},
|
||||
"review": {
|
||||
"title": "Rivedi i dati estratti",
|
||||
"discard": "Scarta",
|
||||
"confirm": "Salva i dati approvati",
|
||||
"confirming": "Salvataggio…",
|
||||
"needsReview": "Da rivedere",
|
||||
@@ -7826,7 +7888,14 @@
|
||||
"reject": "Rifiuta",
|
||||
"edit": "Modifica",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva"
|
||||
"save": "Salva",
|
||||
"needsReviewHint": "Modifica questo dato per poterlo approvare.",
|
||||
"confidenceLabel": "Affidabilità dell'estrazione",
|
||||
"commitError": {
|
||||
"unitMismatch": "l'unità non corrisponde a quella salvata",
|
||||
"unitRequired": "un valore numerico richiede un'unità",
|
||||
"generic": "non è stato possibile salvarlo"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noFacts": "Niente altro da rivedere."
|
||||
@@ -7844,21 +7913,72 @@
|
||||
"dose": "Dose"
|
||||
},
|
||||
"toast": {
|
||||
"uploaded": "Documento letto. Rivedi i dati qui sotto.",
|
||||
"uploadFailed": "Impossibile leggere il documento.",
|
||||
"uploaded": "Documento salvato.",
|
||||
"uploadFailed": "Impossibile salvare il documento.",
|
||||
"confirmed": "Dati approvati salvati.",
|
||||
"confirmedPartial": "Salvato, ma {count} non è stato possibile salvare: {reason}.",
|
||||
"confirmFailed": "Impossibile salvare le tue scelte.",
|
||||
"saved": "Dato aggiornato.",
|
||||
"discarded": "Documento scartato."
|
||||
"discarded": "Documento scartato.",
|
||||
"extracted": "Dati estratti. Rivedili qui sotto.",
|
||||
"extractFailed": "Impossibile estrarre i dati.",
|
||||
"localOcrDisabled": "Attiva l'OCR locale nelle impostazioni per leggere questo documento sul dispositivo.",
|
||||
"updated": "Documento aggiornato.",
|
||||
"updateFailed": "Impossibile aggiornare il documento.",
|
||||
"discardFailed": "Impossibile eliminare il documento.",
|
||||
"uploadTooLarge": "Il file è troppo grande (max 12 MB).",
|
||||
"uploadFileType": "Carica un file JPEG, PNG, WebP o PDF.",
|
||||
"uploadRateLimited": "Troppi caricamenti. Riprova più tardi.",
|
||||
"uploadInvalidMetadata": "Controlla titolo, categoria o data e riprova."
|
||||
},
|
||||
"toolbar": {
|
||||
"searchLabel": "Cerca",
|
||||
"searchPlaceholder": "Titolo o nome file",
|
||||
"kindFilterLabel": "Categoria",
|
||||
"allKinds": "Tutte le categorie",
|
||||
"sortLabel": "Ordina per",
|
||||
"sort": {
|
||||
"documentDate": "Data del documento",
|
||||
"createdAt": "Data di aggiunta",
|
||||
"title": "Titolo"
|
||||
},
|
||||
"orderAsc": "Crescente",
|
||||
"orderDesc": "Decrescente",
|
||||
"fromLabel": "Dal",
|
||||
"toLabel": "Al"
|
||||
},
|
||||
"detail": {
|
||||
"viewOriginal": "Vedi originale",
|
||||
"edit": "Modifica",
|
||||
"extract": "Estrai dati",
|
||||
"extracting": "Estrazione…",
|
||||
"discard": "Scarta",
|
||||
"notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento.",
|
||||
"viewOriginalNewTab": "(si apre in una nuova scheda)",
|
||||
"discardConfirmTitle": "Eliminare questo documento?",
|
||||
"discardConfirmBody": "Questo elimina definitivamente il documento, il file originale e i dati preparati. L'operazione non è reversibile.",
|
||||
"discardConfirmAction": "Elimina",
|
||||
"discardCancel": "Annulla"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifica documento",
|
||||
"subtitle": "Rinomina, ricategorizza o imposta la data del documento.",
|
||||
"titleLabel": "Titolo",
|
||||
"kindLabel": "Categoria",
|
||||
"dateLabel": "Data del documento",
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio…",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"note": {
|
||||
"providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.",
|
||||
"alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo."
|
||||
},
|
||||
"committed": {
|
||||
"title": "Approvato da questo documento"
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog è uno strumento personale per la registrazione della salute e il benessere. Ti aiuta a registrare, visualizzare ed esportare i tuoi dati di salute. Non diagnostica, cura, guarisce né previene alcuna malattia e non è un dispositivo medico.",
|
||||
"coach": "Il Coach lavora sui tuoi dati registrati. Non è un medico e non fornisce consigli medici: per una diagnosi o una terapia rivolgiti a un professionista sanitario.",
|
||||
"insights": "Sono andamenti descrittivi calcolati dai tuoi dati registrati, non una valutazione clinica né una diagnosi.",
|
||||
"labs": "Sono i tuoi referti, non una diagnosi medica. Gli intervalli di riferimento variano da laboratorio a persona: solo il tuo medico può interpretarli per te.",
|
||||
"medications": "Questo registra i farmaci che annoti e la tua aderenza. Non valuta dosaggi o interazioni e non è un consiglio medico: confermali con il farmacista o il medico.",
|
||||
"emergency": "Se potrebbe trattarsi di un'emergenza, contatta subito il numero di emergenza locale o un medico. HealthLog non può aiutare in caso di emergenza.",
|
||||
"dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute."
|
||||
}
|
||||
}
|
||||
+193
-73
@@ -4018,16 +4018,46 @@
|
||||
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sekcje ustawień"
|
||||
"sectionsNav": "Sekcje ustawień",
|
||||
"subSectionsNav": "Podsekcje ustawień"
|
||||
},
|
||||
"groups": {
|
||||
"account": {
|
||||
"title": "Konto"
|
||||
},
|
||||
"tracking": {
|
||||
"title": "Śledzenie"
|
||||
},
|
||||
"display": {
|
||||
"title": "Wyświetlanie"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integracje"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia"
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI i Coach"
|
||||
},
|
||||
"access": {
|
||||
"title": "API i dostęp"
|
||||
},
|
||||
"data": {
|
||||
"title": "Dane i prywatność"
|
||||
},
|
||||
"about": {
|
||||
"title": "O aplikacji"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"title": "Profil i preferencje",
|
||||
"description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.",
|
||||
"subtitle": "Twój profil, tożsamość i dane logowania."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integracje",
|
||||
"title": "Połączenia",
|
||||
"description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
|
||||
"subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami."
|
||||
},
|
||||
@@ -4036,11 +4066,10 @@
|
||||
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
|
||||
"channelsHintLink": "Integracje → Kanały",
|
||||
"inboxLink": "Otwórz skrzynkę odbiorczą",
|
||||
"subtitle": "Wybierz, które przypomnienia otrzymujesz."
|
||||
},
|
||||
"layout": {
|
||||
"title": "Pulpit",
|
||||
"title": "Wyświetlanie",
|
||||
"description": "Ustal, jak rozłożone są pulpit i analizy.",
|
||||
"backToHub": "Powrót do Układ i personalizacja",
|
||||
"dashboard": {
|
||||
@@ -4319,9 +4348,9 @@
|
||||
"subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Dane i prywatność",
|
||||
"subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.",
|
||||
"description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń."
|
||||
"title": "Prywatność i przechowywanie",
|
||||
"subtitle": "Twoje dane, retencja i szyfrowanie w jednym miejscu.",
|
||||
"description": "Eksport, usuwanie, retencja i stan szyfrowania."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Łącznik MCP",
|
||||
@@ -4665,7 +4694,9 @@
|
||||
"focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…",
|
||||
"completeness": "Wypełnione: {answered} z {total}",
|
||||
"questionsTitle": "Coach ma pytania",
|
||||
"questionsOpenCoach": "Odpowiedz w Coachu"
|
||||
"questionsOpenCoach": "Odpowiedz w Coachu",
|
||||
"recordsLink": "Alergie, wywiad rodzinny i Twoje choroby przewlekłe są częścią Twojego wywiadu medycznego.",
|
||||
"recordsLinkCta": "Otwórz wywiad medyczny"
|
||||
},
|
||||
"responseTimeoutHeading": "Limit czasu odpowiedzi",
|
||||
"responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.",
|
||||
@@ -5286,6 +5317,11 @@
|
||||
"description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.",
|
||||
"openAi": "Tryb prywatności AI",
|
||||
"openResearch": "Tryb badawczy"
|
||||
},
|
||||
"signIn": {
|
||||
"title": "Logowanie i urządzenia",
|
||||
"description": "Aktywne sesje, zaufane urządzenia i aktywność logowania znajdują się teraz w ustawieniach zabezpieczeń konta.",
|
||||
"openSecurity": "Zarządzaj logowaniem i zabezpieczeniami"
|
||||
}
|
||||
},
|
||||
"mcp": {
|
||||
@@ -7061,9 +7097,14 @@
|
||||
"restoredToast": "Pomiar przywrócony",
|
||||
"restoreError": "Nie udało się przywrócić pomiaru",
|
||||
"detail": {
|
||||
"history": "Historia",
|
||||
"emptyTitle": "Brak pomiarów",
|
||||
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie."
|
||||
"emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie.",
|
||||
"title": "Biomarker",
|
||||
"genericDescription": "Biomarker laboratoryjny, który śledzisz. Dodaj pomiary, aby obserwować jego trend względem zakresu referencyjnego.",
|
||||
"valuesTitle": "{marker} — wszystkie odczyty",
|
||||
"valuesDescription": "Każdy odczyt tego biomarkera. Edytuj lub usuń wpis bezpośrednio.",
|
||||
"backToMarker": "Powrót do {marker}",
|
||||
"latestResult": "Ostatni wynik"
|
||||
},
|
||||
"chart": {
|
||||
"empty": "Brak pomiarów w tym okresie.",
|
||||
@@ -7172,36 +7213,36 @@
|
||||
"bloodCount": "Morfologia krwi"
|
||||
},
|
||||
"desc": {
|
||||
"total-cholesterol": "Całkowita ilość cholesterolu transportowanego we krwi, suma frakcji LDL, HDL i pozostałych lipoprotein.",
|
||||
"ldl": "Cholesterol transportowany przez lipoproteiny o niskiej gęstości, frakcja odkładająca się w ścianach tętnic.",
|
||||
"hdl": "Cholesterol transportowany przez lipoproteiny o wysokiej gęstości, które odprowadzają go do wątroby.",
|
||||
"triglycerides": "Główny tłuszcz krążący we krwi, pochodzący z diety i wytwarzany przez wątrobę.",
|
||||
"fasting-glucose": "Poziom cukru we krwi mierzony na czczo, odzwierciedla podstawową regulację glukozy.",
|
||||
"hba1c": "Odsetek hemoglobiny związanej z glukozą, odzwierciedla poziom cukru z ostatnich dwóch–trzech miesięcy.",
|
||||
"tsh": "Hormon przysadki sterujący tarczycą; podstawowy test czynności tarczycy.",
|
||||
"ft3": "Wolna, aktywna postać trijodotyroniny, silniejszego hormonu tarczycy.",
|
||||
"ft4": "Wolna postać tyroksyny, głównego hormonu uwalnianego przez tarczycę do krwi.",
|
||||
"ferritin": "Główne białko magazynujące żelazo, odzwierciedla zapasy żelaza w organizmie.",
|
||||
"transferrin-saturation": "Odsetek transferyny, białka transportującego żelazo, które w danym momencie przenosi żelazo.",
|
||||
"vitamin-d": "Krążąca 25-hydroksywitamina D, standardowy wskaźnik stanu witaminy D.",
|
||||
"vitamin-b12": "Witamina niezbędna do tworzenia czerwonych krwinek i funkcji nerwów.",
|
||||
"folate": "Witamina z grupy B potrzebna do tworzenia czerwonych krwinek i syntezy DNA.",
|
||||
"hs-crp": "Białko wątrobowe rosnące w stanach zapalnych, mierzone z wysoką czułością.",
|
||||
"creatinine": "Produkt przemiany mięśni usuwany przez nerki, służy do oceny czynności nerek.",
|
||||
"egfr": "Szacowany współczynnik filtracji nerek, obliczany na podstawie kreatyniny.",
|
||||
"alt": "Enzym wątrobowy uwalniany do krwi przy uszkodzeniu komórek wątroby.",
|
||||
"ast": "Enzym wątroby i mięśni rosnący przy uszkodzeniu tych tkanek.",
|
||||
"ggt": "Enzym wątroby i dróg żółciowych rosnący przy zastoju żółci lub spożyciu alkoholu.",
|
||||
"sodium": "Główny elektrolit regulujący gospodarkę wodną i objętość krwi.",
|
||||
"potassium": "Elektrolit niezbędny do przewodzenia nerwowego i pracy mięśnia sercowego.",
|
||||
"hemoglobin": "Białko czerwonych krwinek transportujące tlen.",
|
||||
"hematocrit": "Udział czerwonych krwinek w objętości krwi.",
|
||||
"wbc": "Całkowita liczba białych krwinek, krążącej obrony układu odpornościowego.",
|
||||
"platelets": "Fragmenty komórkowe, które zlepiają się, tworząc skrzepy krwi.",
|
||||
"apob": "Bezpośrednio liczy cząstki aterogenne; często lepszy marker ryzyka niż LDL-C, gdy się różnią.",
|
||||
"lp-a": "W dużej mierze dziedziczny, dożywotni czynnik ryzyka — zwykle mierzony raz. Podawaj w jednostkach molowych (nmol/L).",
|
||||
"fasting-insulin": "Łączy się z glukozą na czczo do oszacowania insulinooporności (HOMA-IR).",
|
||||
"omega-3-index": "Udział EPA+DHA w czerwonych krwinkach; 8–12% to powszechnie podawany zakres kardioprotekcyjny."
|
||||
"total-cholesterol": "Cholesterol całkowity to suma cholesterolu przenoszonego przez wszystkie cząstki lipoprotein we krwi, w tym frakcje LDL, HDL i pozostałości. Sam cholesterol jest niezbędnym budulcem błon komórkowych, hormonów i kwasów żółciowych. Jako pojedyncza liczba pozostaje miarą zgrubną, ponieważ zarówno wysoki udział HDL, jak i wysoki udział LDL podnoszą sumę — dlatego zwykle odczytuje się go wraz z poszczególnymi frakcjami. Oznacza się go przede wszystkim w ramach długoterminowej oceny ryzyka sercowo-naczyniowego.",
|
||||
"ldl": "Cholesterol LDL to cholesterol przenoszony przez cząstki lipoprotein o małej gęstości, czyli frakcja, która dostarcza cholesterol do tkanek, ale może też odkładać go w ścianach tętnic. Przez lata odkładanie to przyczynia się do blaszki miażdżycowej, dlatego LDL jest jednym z najbaczniej obserwowanych markerów lipidowych. Mniejsza ekspozycja w długim okresie zwykle wiąże się z niższym ryzykiem sercowo-naczyniowym. Interpretuje się go według kategorii ryzyka, a nie wobec jednej stałej wartości prawidłowej.",
|
||||
"hdl": "Cholesterol HDL to cholesterol przenoszony przez cząstki lipoprotein o dużej gęstości, które zbierają nadmiar cholesterolu z tkanek i odprowadzają go do wątroby w celu ponownego wykorzystania lub usunięcia. Z powodu tej funkcji transportowej często nazywa się go frakcją ochronną. Bardzo niskie wartości zwykle towarzyszą wyższemu ryzyku sercowo-naczyniowemu, jednak sztuczne jego podnoszenie nie obniżało ryzyka w sposób wiarygodny, dlatego odczytuje się go jako marker, a nie cel. Zwykle interpretuje się go razem z LDL i trójglicerydami.",
|
||||
"triglycerides": "Trójglicerydy to główna postać tłuszczu krążącego we krwi, dostarczana zarówno z dietą, jak i wytwarzana przez wątrobę, i stanowią kluczowy zapas energii. Wartości rosną po posiłkach, dlatego próbkę pobiera się zwykle na czczo, aby była porównywalna. Utrzymujące się wysokie trójglicerydy wiążą się z ryzykiem metabolicznym i sercowo-naczyniowym i często towarzyszą insulinooporności oraz niskiemu HDL. Odczytuje się je jako jeden ze składników całościowego obrazu lipidowego.",
|
||||
"fasting-glucose": "Glukoza na czczo to stężenie cukru we krwi mierzone po nocnym poście, odzwierciedlające, jak organizm utrzymuje swój podstawowy poziom cukru bez niedawnego posiłku. Glukoza jest głównym natychmiastowym paliwem organizmu, a insulina utrzymuje ją zwykle w wąskim zakresie. Pomiar na czczo eliminuje wpływ niedawnych posiłków, co czyni go standardowym badaniem przesiewowym działania regulacji glukozy. To jeden z markerów, którymi ocenia się zdrowie metaboliczne w czasie.",
|
||||
"hba1c": "HbA1c to odsetek hemoglobiny w czerwonych krwinkach, który związał się z glukozą i gromadzi się proporcjonalnie do średniej ekspozycji na cukier we krwi. Ponieważ czerwone krwinki żyją około trzech miesięcy, odzwierciedla średnie stężenie glukozy z poprzednich dwóch–trzech miesięcy, a nie pojedynczej chwili. Czyni to z niego stabilny, niezależny od posiłków obraz kontroli glikemii. Jego wiarygodność mogą zaburzać stany zmieniające czas życia czerwonych krwinek.",
|
||||
"tsh": "TSH (hormon tyreotropowy) jest uwalniany przez przysadkę, aby sygnalizować tarczycy wytwarzanie jej hormonów, i dostosowuje się w górę lub w dół, by utrzymać je w równowadze. Dzięki tej pętli sprzężenia zwrotnego TSH jest najczulszym badaniem przesiewowym pierwszego rzutu funkcji tarczycy: zwykle zmienia się, zanim same hormony tarczycy odbiegną od normy. Podwyższona lub obniżona wartość wskazuje na niedoczynną lub nadczynną tarczycę i zwykle prowadzi do bezpośredniego oznaczenia hormonów tarczycy. Ciąża i wiek przesuwają oczekiwany zakres.",
|
||||
"ft3": "Wolne T3 (trójjodotyronina) to niezwiązana, biologicznie czynna postać silniejszego hormonu tarczycy — frakcja dostępna do działania na tkanki. Większość krążącego T3 jest związana z białkami nośnikowymi i nieaktywna, dlatego wolna część odzwierciedla rzeczywistą dostępność hormonu. Oznacza się je, aby scharakteryzować funkcję tarczycy, zwłaszcza wraz z TSH i wolnym T4, gdy wyniki są niejasne. Wartości silnie zależą od metody oznaczenia i odczytuje się je w kontekście klinicznym.",
|
||||
"ft4": "Wolne T4 (tyroksyna) to niezwiązana postać głównego hormonu, który tarczyca uwalnia do krwi; służy jako rezerwa, którą tkanki w razie potrzeby przekształcają w bardziej aktywne T3. Wolna frakcja odzwierciedla to, co jest faktycznie dostępne dla organizmu, niezależnie od poziomu białek nośnikowych. Zwykle oznacza się je wraz z TSH, aby odróżnić problemy samej tarczycy od zaburzeń sygnalizacji przysadkowej. Ciąża i niektóre leki mogą je przesuwać.",
|
||||
"ferritin": "Ferrytyna to główne białko magazynujące żelazo w organizmie, a jej poziom we krwi z grubsza odzwierciedla całkowite zapasy żelaza. Niska wartość wskazuje na wyczerpane zapasy, a wysoka może odzwierciedlać przeładowanie żelazem — lub po prostu stan zapalny, ponieważ ferrytyna rośnie także jako białko ostrej fazy. Z tego powodu często interpretuje się ją wraz z innymi markerami żelaza i oznakami zapalenia. To jedna z podstawowych miar oceny gospodarki żelazem.",
|
||||
"transferrin-saturation": "Wysycenie transferyny to odsetek transferyny — białka przenoszącego żelazo we krwi — który jest aktualnie obładowany żelazem. Łączy ono ilość żelaza w transporcie ze zdolnością organizmu do jego przenoszenia, dając migawkę dostępności żelaza, która uzupełnia obraz zapasów dawany przez ferrytynę. Niskie wysycenie sugeruje niedobór żelaza, a wysoka wartość może wskazywać na przeładowanie. Odczytuje się je wraz z ferrytyną i pełnym profilem żelaza.",
|
||||
"vitamin-d": "Witaminę D oznacza się jako 25-hydroksywitaminę D, krążącą postać magazynową i standardowy marker ogólnego stanu witaminy D. Organizm wytwarza ją w skórze wystawionej na światło słoneczne i część przyswaja z pożywienia; wspiera ona gospodarkę wapniową, zdrowie kości i funkcję odpornościową. Wartości zmieniają się wraz z porą roku, szerokością geograficzną, odcieniem skóry i suplementacją. Oznacza się ją, aby z czasem ocenić, czy zapasy są wystarczające.",
|
||||
"vitamin-b12": "Witamina B12 (kobalamina) to niezbędna witamina pozyskiwana głównie z produktów pochodzenia zwierzęcego i magazynowana w dużej mierze w wątrobie. Jest potrzebna do tworzenia czerwonych krwinek, syntezy DNA i zdrowia układu nerwowego. Długotrwały niedobór — z powodu małego spożycia lub upośledzonego wchłaniania — może prowadzić do niedokrwistości i objawów neurologicznych. Oznacza się ją, aby ocenić stan odżywienia, zwłaszcza tam, gdzie spożycie lub wchłanianie mogą być ograniczone.",
|
||||
"folate": "Foliany (witamina B9) to witamina z grupy B obecna w zielonych warzywach liściastych, roślinach strączkowych i żywności wzbogacanej, kluczowa dla tworzenia czerwonych krwinek i syntezy DNA. Działa w ścisłym powiązaniu z witaminą B12, a niedobór któregokolwiek z nich może wywołać podobny rodzaj niedokrwistości. Odpowiednia ilość folianów jest szczególnie ważna na początku ciąży dla zdrowego rozwoju płodu. Oznacza się je, aby ocenić stan odżywienia i przyczynę niektórych niedokrwistości.",
|
||||
"hs-crp": "Wysokoczułe CRP mierzy białko C-reaktywne, marker uwalniany przez wątrobę w odpowiedzi na stan zapalny, metodą dostrojoną do wykrywania niskich stężeń istotnych dla ryzyka sercowo-naczyniowego. W odróżnieniu od standardowego CRP stosowanego przy ostrych infekcjach wersja wysokoczuła wychwytuje subtelny, przewlekły stan zapalny powiązany z chorobą naczyń. Ponieważ każda niedawna infekcja lub uraz może je podnieść, interpretuje się je z dala od ostrej choroby i często potwierdza powtórną próbką. Odczytuje się je jako jeden z czynników całościowego ryzyka sercowo-naczyniowego.",
|
||||
"creatinine": "Kreatynina to produkt przemiany materii prawidłowego metabolizmu mięśni, który nerki filtrują z krwi w dość stałym tempie. Ponieważ jej wytwarzanie jest stosunkowo stałe, jej poziom we krwi służy jako praktyczna miara tego, jak dobrze nerki usuwają produkty przemiany materii. Na wartości wpływają też masa mięśniowa, nawodnienie i dieta, dlatego zwykle interpretuje się je wraz z szacowanym współczynnikiem filtracji. To podstawowy marker funkcji nerek.",
|
||||
"egfr": "eGFR (szacowany współczynnik filtracji kłębuszkowej) szacuje, ile krwi nerki filtrują w ciągu minuty, na podstawie kreatyniny oraz wieku i płci. Przekłada surową wartość kreatyniny na bardziej bezpośrednio zrozumiałą miarę funkcji nerek. Spadek eGFR w czasie to główny sposób klasyfikowania i śledzenia przewlekłych zmian w nerkach. Jako wartość szacunkowa odczytuje się go jako trend, a nie pojedynczą dokładną liczbę.",
|
||||
"alt": "ALT (aminotransferaza alaninowa) to enzym skupiony w komórkach wątroby, uwalniany do krwi, gdy komórki te są obciążone lub uszkodzone. Ponieważ jest stosunkowo swoisty dla wątroby, należy do głównych markerów wykrywania uszkodzenia lub zapalenia wątroby. Wartości mogą rosnąć przy stłuszczeniu wątroby, wirusowym zapaleniu wątroby, alkoholu lub niektórych lekach. Zwykle odczytuje się go wraz z AST i pozostałymi markerami profilu wątrobowego.",
|
||||
"ast": "AST (aminotransferaza asparaginianowa) to enzym obecny w wątrobie, ale także w mięśniach, sercu i czerwonych krwinkach, uwalniany przy uszkodzeniu którejkolwiek z tych tkanek. Ponieważ jest mniej swoisty dla wątroby niż ALT, oba interpretuje się razem, a ich stosunek może wskazywać na źródło i charakter problemu. Intensywny wysiłek lub uraz mięśni mogą go podnieść niezależnie od wątroby. To jeden ze standardowych enzymów profilu wątrobowego.",
|
||||
"ggt": "GGT (gamma-glutamylotransferaza) to enzym wątroby i dróg żółciowych, który rośnie zwłaszcza przy utrudnionym odpływie żółci lub w wyniku ekspozycji na alkohol. Często służy do wyjaśnienia, dlaczego inny marker wątrobowy jest podwyższony — na przykład, aby odróżnić źródło wątrobowe od żółciowego. Jest czuły, ale mało swoisty, dlatego odczytuje się go w kontekście reszty profilu wątrobowego. To jeden z kilku markerów używanych do oceny zdrowia wątroby i dróg żółciowych.",
|
||||
"sodium": "Sód to główny elektrolit płynu poza komórkami organizmu i podstawowy regulator gospodarki płynami oraz objętości krwi. Organizm reguluje go ściśle poprzez pragnienie, hormony i nerki, dlatego jego poziom we krwi odzwierciedla raczej równowagę między wodą a solą niż samo spożycie. Nieprawidłowe wartości zwykle sygnalizują zaburzenie gospodarki wodnej, a nie samego sodu. To podstawowy element panelu metabolicznego i elektrolitowego.",
|
||||
"potassium": "Potas to główny elektrolit wewnątrz komórek organizmu i jest niezbędny do przewodzenia nerwowego oraz równego rytmu mięśnia sercowego. Jego poziom we krwi utrzymywany jest w wąskim zakresie przez nerki i sygnały hormonalne, ponieważ zarówno wysokie, jak i niskie wartości mogą zaburzać aktywność elektryczną serca. Na wartości mogą wpływać funkcja nerek, nawodnienie i kilka powszechnych leków. To podstawowa miara panelu elektrolitowego.",
|
||||
"hemoglobin": "Hemoglobina to zawierające żelazo białko czerwonych krwinek, które wiąże tlen w płucach i przenosi go do tkanek. Jej stężenie jest główną miarą zdolności krwi do przenoszenia tlenu i centralnym markerem wykrywania niedokrwistości. Niskie wartości mogą odzwierciedlać utratę krwi, niedobór żelaza lub witamin albo zmniejszone wytwarzanie, a wysokie mogą następować po odwodnieniu lub w warunkach niedoboru tlenu. To filar pełnej morfologii krwi.",
|
||||
"hematocrit": "Hematokryt to udział czerwonych krwinek w objętości krwi, wyrażony w procentach. Zmienia się ściśle wraz z hemoglobiną i daje uzupełniający obraz masy czerwonych krwinek oraz zdolności krwi do przenoszenia tlenu. Wpływa na niego nawodnienie, ponieważ zmiany objętości osocza przesuwają ten udział nawet wtedy, gdy liczba czerwonych krwinek pozostaje stała. Odczytuje się go wraz z hemoglobiną i resztą morfologii.",
|
||||
"wbc": "Liczba białych krwinek to całkowita liczba komórek odpornościowych krążących we krwi, ruchomej obrony organizmu przed infekcjami. Rośnie w odpowiedzi na infekcję, stan zapalny lub stres i może spadać, gdy szpik kostny jest osłabiony lub w niektórych chorobach odpornościowych. Ponieważ sumuje kilka odrębnych typów komórek, wyraźną zmianę często uzupełnia się rozmazem, który rozbija ją na składowe. To podstawowy element pełnej morfologii krwi.",
|
||||
"platelets": "Płytki krwi to drobne fragmenty komórek we krwi, które zlepiają się i tworzą początkowy czop zatrzymujący krwawienie z uszkodzonego naczynia. Ich liczba odzwierciedla równowagę między wytwarzaniem w szpiku kostnym a zużyciem lub rozpadem. Niska liczba może zwiększać skłonność do krwawień, a wysoka może następować po stanie zapalnym lub innych bodźcach. Stanowią część pełnej morfologii krwi i są podstawową miarą zdolności krzepnięcia.",
|
||||
"apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL — zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.",
|
||||
"lp-a": "Lipoproteina(a) to cząstka podobna do LDL z dodatkowo przyłączonym białkiem, której poziom jest w dużej mierze uwarunkowany genetycznie i pozostaje dość stabilny przez całe życie. Podwyższona Lp(a) jest niezależnym, dziedzicznym czynnikiem ryzyka chorób sercowo-naczyniowych i zwężenia zastawki aortalnej. Ponieważ niewiele się zmienia, zwykle oznacza się ją raz, aby ustalić ryzyko danej osoby na całe życie, a nie śledzić wielokrotnie. Podaje się ją w jednostkach molowych i odczytuje w ramach całościowego ryzyka sercowo-naczyniowego.",
|
||||
"fasting-insulin": "Insulina na czczo to poziom insuliny — hormonu, który przenosi glukozę z krwi do komórek — mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.",
|
||||
"omega-3-index": "Indeks omega-3 to udział EPA i DHA — dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3 — wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
@@ -7318,8 +7359,8 @@
|
||||
"description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone."
|
||||
},
|
||||
"inboundDocuments": {
|
||||
"label": "Dokumenty przychodzące",
|
||||
"description": "Prześlij raport lekarski lub wypis; sprawdź wyodrębnione dane przed zapisaniem. Domyślnie wyłączone."
|
||||
"label": "Dokumenty",
|
||||
"description": "Przechowuj swoje dokumenty medyczne; opcjonalnie wyodrębniaj i przeglądaj ich fakty przed zapisaniem. Domyślnie wyłączone."
|
||||
},
|
||||
"mentalHealth": {
|
||||
"label": "Dobrostan psychiczny",
|
||||
@@ -7558,8 +7599,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Sprawdzenie dobrostanu psychicznego",
|
||||
"pageDescription": "Dwa zwalidowane, dobrowolne testy — PHQ-9 (nastrój) i GAD-7 (lęk) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
|
||||
"disclaimer": "To przesiewowa samoocena, a nie diagnoza. Wysoki lub niski wynik nie oznacza, że masz lub nie masz danego schorzenia. Tylko wykwalifikowany pracownik ochrony zdrowia może postawić diagnozę. W razie wątpliwości porozmawiaj z lekarzem.",
|
||||
"optInNote": "Dobrowolne i prywatne: Twoje poszczególne odpowiedzi są szyfrowane, pozostają poza Coachem AI i eksportami i nigdy nie są udostępniane.",
|
||||
"infoTooltip": "To jest przesiewowy autotest, a nie diagnoza.",
|
||||
"attributionLabel": "Źródło",
|
||||
"choosePrompt": "Wybierz sprawdzenie",
|
||||
"start": "Rozpocznij",
|
||||
@@ -7774,6 +7814,15 @@
|
||||
"updatedToast": "Wpis zaktualizowany",
|
||||
"saveError": "Nie udało się zapisać wpisu."
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Choroby przewlekłe",
|
||||
"cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę — z Twojego kontekstu osobistego.",
|
||||
"conditionsLabel": "Choroby przewlekłe",
|
||||
"focusLabel": "Na co zwraca uwagę Coach",
|
||||
"empty": "Nic jeszcze nie zapisano.",
|
||||
"editCta": "Edytuj w kontekście osobistym",
|
||||
"loadError": "Nie udało się wczytać Twoich chorób."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -7788,37 +7837,50 @@
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Dokumenty przychodzące",
|
||||
"subtitle": "Prześlij dokument kliniczny i sprawdź dane, które z niego odczytaliśmy.",
|
||||
"disclaimer": {
|
||||
"title": "Odczytujemy, nie interpretujemy",
|
||||
"body": "Przepisujemy tylko to, co podaje dokument — wartości, kody i daty — i nigdy nie stawiamy diagnozy, nie interpretujemy ani niczego nie oznaczamy. Nic nie zostaje zapisane, dopóki tego nie sprawdzisz i nie zatwierdzisz. Tylko lekarz może zinterpretować te wyniki."
|
||||
},
|
||||
"title": "Dokumenty",
|
||||
"subtitle": "Przechowuj swoje dokumenty medyczne i sprawdzaj, co zawierają.",
|
||||
"upload": {
|
||||
"label": "Prześlij dokument",
|
||||
"kindLabel": "Typ dokumentu",
|
||||
"label": "Dodaj dokument",
|
||||
"kindLabel": "Kategoria",
|
||||
"button": "Prześlij",
|
||||
"uploading": "Odczytywanie…",
|
||||
"fileLabel": "Plik dokumentu"
|
||||
"uploading": "Przesyłanie…",
|
||||
"fileLabel": "Plik (wymagany)",
|
||||
"titleLabel": "Tytuł (opcjonalnie)",
|
||||
"titlePlaceholder": "np. Raport kardiologiczny",
|
||||
"dateLabel": "Data dokumentu (opcjonalnie)",
|
||||
"fileRequired": "Wybierz plik do przesłania.",
|
||||
"accepts": "JPEG, PNG, WebP lub PDF · do 12 MB"
|
||||
},
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Raport lekarski",
|
||||
"DISCHARGE_LETTER": "Wypis",
|
||||
"OTHER": "Inny"
|
||||
"DISCHARGE_LETTER": "Wypis ze szpitala",
|
||||
"OTHER": "Inne",
|
||||
"LAB_RESULT": "Wynik laboratoryjny",
|
||||
"IMAGING": "Obrazowanie",
|
||||
"PRESCRIPTION": "Recepta",
|
||||
"INSURANCE": "Ubezpieczenie",
|
||||
"VACCINATION": "Szczepienie"
|
||||
},
|
||||
"list": {
|
||||
"empty": "Brak dokumentów."
|
||||
"empty": "Brak dokumentów.",
|
||||
"noResults": "Żaden dokument nie pasuje do filtrów.",
|
||||
"loadMore": "Załaduj więcej",
|
||||
"loadingMore": "Ładowanie…",
|
||||
"pending": "{count} do przeglądu",
|
||||
"facts": "{count} faktów",
|
||||
"clearFilters": "Wyczyść filtry",
|
||||
"loadError": "Nie udało się wczytać Twoich dokumentów."
|
||||
},
|
||||
"status": {
|
||||
"EXTRACTING": "Odczytywanie",
|
||||
"EXTRACTED": "Gotowe do przeglądu",
|
||||
"EXTRACTING": "Odczyt",
|
||||
"EXTRACTED": "Gotowy do przeglądu",
|
||||
"FAILED": "Nie udało się odczytać",
|
||||
"CONFIRMED": "Sprawdzone",
|
||||
"DISCARDED": "Odrzucone"
|
||||
"CONFIRMED": "Przejrzany",
|
||||
"DISCARDED": "Odrzucony",
|
||||
"STORED": "Zapisany"
|
||||
},
|
||||
"review": {
|
||||
"title": "Przejrzyj wyodrębnione dane",
|
||||
"discard": "Odrzuć",
|
||||
"confirm": "Zapisz zatwierdzone dane",
|
||||
"confirming": "Zapisywanie…",
|
||||
"needsReview": "Wymaga przeglądu",
|
||||
@@ -7826,7 +7888,14 @@
|
||||
"reject": "Odrzuć",
|
||||
"edit": "Edytuj",
|
||||
"cancel": "Anuluj",
|
||||
"save": "Zapisz"
|
||||
"save": "Zapisz",
|
||||
"needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.",
|
||||
"confidenceLabel": "Pewność rozpoznania",
|
||||
"commitError": {
|
||||
"unitMismatch": "jednostka nie zgadza się z zapisaną",
|
||||
"unitRequired": "wartość liczbowa wymaga jednostki",
|
||||
"generic": "nie udało się zapisać"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"noFacts": "Nic więcej do przejrzenia."
|
||||
@@ -7844,21 +7913,72 @@
|
||||
"dose": "Dawka"
|
||||
},
|
||||
"toast": {
|
||||
"uploaded": "Dokument odczytany. Sprawdź dane poniżej.",
|
||||
"uploadFailed": "Nie udało się odczytać dokumentu.",
|
||||
"uploaded": "Dokument zapisany.",
|
||||
"uploadFailed": "Nie udało się zapisać dokumentu.",
|
||||
"confirmed": "Zatwierdzone dane zapisane.",
|
||||
"confirmedPartial": "Zapisano, ale {count} nie udało się zapisać — {reason}.",
|
||||
"confirmFailed": "Nie udało się zapisać Twoich decyzji.",
|
||||
"saved": "Dane zaktualizowane.",
|
||||
"discarded": "Dokument odrzucony."
|
||||
"discarded": "Dokument odrzucony.",
|
||||
"extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.",
|
||||
"extractFailed": "Nie udało się wyodrębnić faktów.",
|
||||
"localOcrDisabled": "Włącz lokalne OCR w ustawieniach, aby odczytać ten dokument na urządzeniu.",
|
||||
"updated": "Dokument zaktualizowany.",
|
||||
"updateFailed": "Nie udało się zaktualizować dokumentu.",
|
||||
"discardFailed": "Nie udało się odrzucić dokumentu.",
|
||||
"uploadTooLarge": "Plik jest za duży (maks. 12 MB).",
|
||||
"uploadFileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
|
||||
"uploadRateLimited": "Zbyt wiele przesłań. Spróbuj ponownie później.",
|
||||
"uploadInvalidMetadata": "Sprawdź tytuł, kategorię lub datę i spróbuj ponownie."
|
||||
},
|
||||
"toolbar": {
|
||||
"searchLabel": "Szukaj",
|
||||
"searchPlaceholder": "Tytuł lub nazwa pliku",
|
||||
"kindFilterLabel": "Kategoria",
|
||||
"allKinds": "Wszystkie kategorie",
|
||||
"sortLabel": "Sortuj według",
|
||||
"sort": {
|
||||
"documentDate": "Data dokumentu",
|
||||
"createdAt": "Data dodania",
|
||||
"title": "Tytuł"
|
||||
},
|
||||
"orderAsc": "Rosnąco",
|
||||
"orderDesc": "Malejąco",
|
||||
"fromLabel": "Od",
|
||||
"toLabel": "Do"
|
||||
},
|
||||
"detail": {
|
||||
"viewOriginal": "Zobacz oryginał",
|
||||
"edit": "Edytuj",
|
||||
"extract": "Wyodrębnij fakty",
|
||||
"extracting": "Wyodrębnianie…",
|
||||
"discard": "Odrzuć",
|
||||
"notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument.",
|
||||
"viewOriginalNewTab": "(otwiera się w nowej karcie)",
|
||||
"discardConfirmTitle": "Odrzucić ten dokument?",
|
||||
"discardConfirmBody": "Spowoduje to trwałe usunięcie dokumentu, oryginalnego pliku i przygotowanych danych. Tej operacji nie można cofnąć.",
|
||||
"discardConfirmAction": "Odrzuć",
|
||||
"discardCancel": "Anuluj"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edytuj dokument",
|
||||
"subtitle": "Zmień nazwę, kategorię lub ustaw datę dokumentu.",
|
||||
"titleLabel": "Tytuł",
|
||||
"kindLabel": "Kategoria",
|
||||
"dateLabel": "Data dokumentu",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie…",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"note": {
|
||||
"providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.",
|
||||
"alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić."
|
||||
},
|
||||
"committed": {
|
||||
"title": "Zatwierdzone z tego dokumentu"
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
"intendedPurpose": "HealthLog to osobiste narzędzie do rejestrowania zdrowia i dbania o dobre samopoczucie. Pomaga zapisywać, wizualizować i eksportować własne dane zdrowotne. Nie diagnozuje, nie leczy ani nie zapobiega żadnej chorobie i nie jest wyrobem medycznym.",
|
||||
"coach": "Coach korzysta z Twoich własnych zapisanych danych. Nie jest lekarzem i nie udziela porad medycznych — po diagnozę lub leczenie zwróć się do pracownika ochrony zdrowia.",
|
||||
"insights": "To opisowe wzorce wyliczone z Twoich własnych zapisanych danych, a nie ocena kliniczna ani diagnoza.",
|
||||
"labs": "To Twoje własne zapisy, a nie diagnoza medyczna. Zakresy referencyjne różnią się w zależności od laboratorium i osoby — tylko Twój lekarz może je dla Ciebie zinterpretować.",
|
||||
"medications": "To rejestruje leki, które zapisujesz, oraz Twoje stosowanie się do zaleceń. Nie ocenia dawkowania ani interakcji i nie jest poradą medyczną — potwierdź to u farmaceuty lub lekarza.",
|
||||
"emergency": "Jeśli to może być nagły przypadek, zadzwoń teraz pod lokalny numer alarmowy lub skontaktuj się z lekarzem. HealthLog nie pomoże w nagłym przypadku.",
|
||||
"dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych."
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,16 @@ const nextConfig: NextConfig = {
|
||||
destination: "/settings/notifications",
|
||||
permanent: true,
|
||||
},
|
||||
// v1.25.1 — the settings IA consolidation made the Display group land
|
||||
// directly on `/settings/dashboard` (its first sub-tab), so the old
|
||||
// "Layout & Personalization" hub at `/settings/layout` is no longer
|
||||
// linked from anywhere. 301-redirect it to the Display landing so any old
|
||||
// bookmark or cached navigation still resolves.
|
||||
{
|
||||
source: "/settings/layout",
|
||||
destination: "/settings/dashboard",
|
||||
permanent: true,
|
||||
},
|
||||
// v1.22.0 — the preventive-care surface moved from the German slug
|
||||
// `/vorsorge` to the English `/checkups` (the feature reads "Checkups"
|
||||
// in every non-German locale now). 301-redirect so bookmarks, the PWA's
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.25.0",
|
||||
"version": "1.25.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
-- v1.25 — documents library: store-first, optional extraction.
|
||||
--
|
||||
-- Inverts the inbound-documents surface from an OCR inbox into a document
|
||||
-- library. A user can now STORE any file (encrypted at rest) without a
|
||||
-- configured document-scan provider; extraction becomes an optional action
|
||||
-- on an already-stored row.
|
||||
--
|
||||
-- Schema deltas on `inbound_documents`:
|
||||
-- * `title` — user-given label, plaintext (mirrors `filename`) so
|
||||
-- the list can ILIKE-search + ORDER BY. The document
|
||||
-- BODY stays encrypted; only this short user-authored
|
||||
-- title is clear-text.
|
||||
-- * `document_date` — user-set filing date (editable). Distinct from
|
||||
-- `report_date`, which stays the model-transcribed
|
||||
-- value.
|
||||
-- * a `(user_id, deleted_at, document_date)` index for date-grouped browse +
|
||||
-- range filter (the list always filters `deleted_at IS NULL`).
|
||||
-- * `inbound_document_kind` gains LAB_RESULT / IMAGING / PRESCRIPTION /
|
||||
-- INSURANCE / VACCINATION (the existing 3 stay; OTHER stays the fallback).
|
||||
-- * `inbound_document_status` gains STORED, made the column DEFAULT (the
|
||||
-- library default — uploaded, no extraction run).
|
||||
--
|
||||
-- Additive; no existing row rewritten (the status type-swap maps every
|
||||
-- existing label to itself). Nullable columns, no backfill: existing rows
|
||||
-- keep their current status; `title` / `document_date` start NULL and resolve
|
||||
-- through the display fallback. Idempotent guards so reruns are safe on prod.
|
||||
--
|
||||
-- Postgres note: a freshly-added enum value cannot be USED (e.g. as a column
|
||||
-- DEFAULT) in the same transaction it is added in. Because Prisma runs each
|
||||
-- migration inside a transaction, STORED cannot be added with `ALTER TYPE ...
|
||||
-- ADD VALUE` and then set as the default in the same file. The status enum is
|
||||
-- therefore recreated (RENAME + CREATE with the full value set + column
|
||||
-- type-swap + SET DEFAULT): a type CREATEd in the current transaction MAY
|
||||
-- have its values used in that same transaction, which makes the default
|
||||
-- change safe. The `kind` additions are not used in this migration, so the
|
||||
-- cheap `ADD VALUE IF NOT EXISTS` form is used for them.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP INDEX IF EXISTS "inbound_documents_user_id_document_date_idx";
|
||||
-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "document_date";
|
||||
-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "title";
|
||||
-- -- (added enum values are inert if unused; a roll-back leaves them
|
||||
-- -- harmless rather than rewriting the type back.)
|
||||
|
||||
-- AlterTable: user-given title + user-set filing date (both nullable).
|
||||
ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "title" TEXT;
|
||||
ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "document_date" TIMESTAMP(3);
|
||||
|
||||
-- AlterEnum: richer document-kind taxonomy (additive; not used in this
|
||||
-- migration, so the new values are safe to add in-place).
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'LAB_RESULT';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'IMAGING';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'PRESCRIPTION';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'INSURANCE';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'VACCINATION';
|
||||
|
||||
-- AlterEnum: add STORED and make it the column default. Recreate the type so
|
||||
-- the new value can be USED as the default in this same transaction (a
|
||||
-- same-transaction CREATE TYPE permits same-transaction use of its values).
|
||||
-- Guarded on STORED-absence so a rerun is a no-op.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_enum e
|
||||
JOIN pg_type t ON e.enumtypid = t.oid
|
||||
WHERE t.typname = 'inbound_document_status' AND e.enumlabel = 'STORED'
|
||||
) THEN
|
||||
ALTER TYPE "inbound_document_status" RENAME TO "inbound_document_status_old";
|
||||
CREATE TYPE "inbound_document_status" AS ENUM (
|
||||
'STORED', 'EXTRACTING', 'EXTRACTED', 'FAILED', 'CONFIRMED', 'DISCARDED'
|
||||
);
|
||||
ALTER TABLE "inbound_documents" ALTER COLUMN "status" DROP DEFAULT;
|
||||
ALTER TABLE "inbound_documents"
|
||||
ALTER COLUMN "status" TYPE "inbound_document_status"
|
||||
USING ("status"::text::"inbound_document_status");
|
||||
ALTER TABLE "inbound_documents" ALTER COLUMN "status" SET DEFAULT 'STORED';
|
||||
DROP TYPE "inbound_document_status_old";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex: date-grouped browse + document_date range filter. The list
|
||||
-- always filters `deleted_at IS NULL` and sorts by `document_date`, so
|
||||
-- deleted_at leads the sort column to keep the scan covered.
|
||||
CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_deleted_at_document_date_idx"
|
||||
ON "inbound_documents"("user_id", "deleted_at", "document_date");
|
||||
|
||||
-- DropIndex: the new `(user_id, deleted_at, document_date)` index above is a
|
||||
-- superset of the plain `(user_id, deleted_at)` index, so the 2-column index is
|
||||
-- redundant for read planning and only adds write/maintenance cost. Drop it.
|
||||
DROP INDEX IF EXISTS "inbound_documents_user_id_deleted_at_idx";
|
||||
+62
-34
@@ -59,10 +59,10 @@ model User {
|
||||
// without a home set) simply has no environment rows. `homeTimezone` anchors
|
||||
// the day-key an observation is stored under (it can differ from the display
|
||||
// `timezone` above for an account that tracks weather for another locale).
|
||||
homeLat Float? @map("home_lat")
|
||||
homeLon Float? @map("home_lon")
|
||||
homeLabel String? @map("home_label")
|
||||
homeTimezone String? @map("home_timezone")
|
||||
homeLat Float? @map("home_lat")
|
||||
homeLon Float? @map("home_lon")
|
||||
homeLabel String? @map("home_label")
|
||||
homeTimezone String? @map("home_timezone")
|
||||
// v1.25 — effective-from instant for the home location. Stamped the moment a
|
||||
// home is set/updated. Day resolution attributes a past day to home ONLY when
|
||||
// the day is on/after this instant; earlier days are skipped (never fabricated
|
||||
@@ -5527,20 +5527,29 @@ model CyclePrediction {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// What kind of clinical document was uploaded (free choice by the user;
|
||||
/// drives no interpretation — it is a label only).
|
||||
/// drives no interpretation — it is a label only). The richer set
|
||||
/// (lab/imaging/prescription/insurance/vaccination) makes the library
|
||||
/// browsable by category; the value still drives no interpretation.
|
||||
enum InboundDocumentKind {
|
||||
DOCTOR_REPORT
|
||||
DISCHARGE_LETTER
|
||||
LAB_RESULT
|
||||
IMAGING
|
||||
PRESCRIPTION
|
||||
INSURANCE
|
||||
VACCINATION
|
||||
OTHER
|
||||
|
||||
@@map("inbound_document_kind")
|
||||
}
|
||||
|
||||
/// Lifecycle of an uploaded document. EXTRACTING while the provider runs,
|
||||
/// EXTRACTED once facts are staged for review, FAILED on a provider/parse
|
||||
/// miss, CONFIRMED once the user has acted on every fact, DISCARDED on a
|
||||
/// soft delete.
|
||||
/// Lifecycle of an uploaded document. STORED is the library default — the
|
||||
/// file is uploaded + encrypted at rest with no extraction run. EXTRACTING
|
||||
/// while the provider runs, EXTRACTED once facts are staged for review,
|
||||
/// FAILED on a provider/parse miss, CONFIRMED once the user has acted on
|
||||
/// every fact, DISCARDED on a soft delete.
|
||||
enum InboundDocumentStatus {
|
||||
STORED
|
||||
EXTRACTING
|
||||
EXTRACTED
|
||||
FAILED
|
||||
@@ -5572,29 +5581,44 @@ enum ExtractedFactStatus {
|
||||
}
|
||||
|
||||
model InboundDocument {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
/// User-given label. Deliberately plaintext (like `filename`) so the library
|
||||
/// list can ILIKE-search + ORDER BY in SQL. The document BODY stays
|
||||
/// encrypted; only this short, user-authored title is clear-text. Tradeoff:
|
||||
/// a title the user types MAY contain PHI (e.g. "HIV result") and is then
|
||||
/// cleartext at rest / in backups — the same exposure the pre-existing
|
||||
/// plaintext `filename` already carries. Accepted to keep server-side search
|
||||
/// and sort.
|
||||
title String?
|
||||
/// Original filename as uploaded (sanitised, optional).
|
||||
filename String?
|
||||
filename String?
|
||||
/// Magic-byte-sniffed MIME type the document was stored as.
|
||||
mimeType String @map("mime_type")
|
||||
mimeType String @map("mime_type")
|
||||
/// Stored byte length of the original (pre-encryption) document.
|
||||
byteSize Int @map("byte_size")
|
||||
byteSize Int @map("byte_size")
|
||||
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
|
||||
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
|
||||
/// shared with the note columns. The original is sensitive and never
|
||||
/// stored in the clear or logged.
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
status InboundDocumentStatus @default(EXTRACTING)
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
status InboundDocumentStatus @default(STORED)
|
||||
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
|
||||
/// "local"). Diagnostic only.
|
||||
providerType String? @map("provider_type")
|
||||
/// "local"). Diagnostic only. Null on a store-only document.
|
||||
providerType String? @map("provider_type")
|
||||
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
|
||||
/// transcribed — never inferred.
|
||||
reportDate DateTime? @map("report_date")
|
||||
/// transcribed by the model — never inferred, never user-set.
|
||||
reportDate DateTime? @map("report_date")
|
||||
/// Effective filing date the library sorts, groups, and range-filters on
|
||||
/// (editable). Distinct from `reportDate`, which is the model-transcribed
|
||||
/// value. On store it defaults to the upload day (now) when the user does
|
||||
/// not supply one, so a stored row's filing date is never NULL and display
|
||||
/// == sort == filter. It stays user-editable and MAY be cleared back to
|
||||
/// NULL via PATCH (the list then sorts such rows last).
|
||||
documentDate DateTime? @map("document_date")
|
||||
/// Failure reason when status = FAILED (a short code, never the document).
|
||||
errorReason String? @map("error_reason")
|
||||
errorReason String? @map("error_reason")
|
||||
|
||||
facts ExtractedFact[]
|
||||
|
||||
@@ -5606,24 +5630,28 @@ model InboundDocument {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([userId, deletedAt])
|
||||
// The list always filters `deletedAt: null` and sorts by `documentDate`, so
|
||||
// deletedAt leads the sort column to keep the scan covered. This composite is
|
||||
// a superset of the plain `(userId, deletedAt)` index, which is therefore
|
||||
// redundant for read planning and dropped.
|
||||
@@index([userId, deletedAt, documentDate])
|
||||
@@map("inbound_documents")
|
||||
}
|
||||
|
||||
model ExtractedFact {
|
||||
id String @id @default(cuid())
|
||||
documentId String @map("document_id")
|
||||
userId String @map("user_id")
|
||||
factType ExtractedFactType @map("fact_type")
|
||||
status ExtractedFactStatus @default(PENDING)
|
||||
id String @id @default(cuid())
|
||||
documentId String @map("document_id")
|
||||
userId String @map("user_id")
|
||||
factType ExtractedFactType @map("fact_type")
|
||||
status ExtractedFactStatus @default(PENDING)
|
||||
/// Overall extraction confidence 0..1 (the lowest per-field score). The
|
||||
/// review screen gates approval of a low-confidence fact behind a manual
|
||||
/// edit (fail-closed).
|
||||
confidence Float @default(0)
|
||||
confidence Float @default(0)
|
||||
/// True when the fact scored below the confidence floor and must be
|
||||
/// reviewed/edited before it can be approved. Cleared once the user edits
|
||||
/// the fact (the values then become user-asserted).
|
||||
needsReview Boolean @default(true) @map("needs_review")
|
||||
needsReview Boolean @default(true) @map("needs_review")
|
||||
/// The FHIR-staged fields, STATED status only, shaped per `factType`
|
||||
/// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10
|
||||
/// / LOINC / RxNorm / ATC) are present only when the document writes them.
|
||||
@@ -5631,20 +5659,20 @@ model ExtractedFact {
|
||||
/// medication names, stated codes) is clinical PHI transcribed verbatim from
|
||||
/// the source document, so it rides the AES-256-GCM `*_encrypted` BYTEA
|
||||
/// convention shared with the note columns rather than as plaintext JSONB.
|
||||
dataEncrypted Bytes @map("data_encrypted")
|
||||
dataEncrypted Bytes @map("data_encrypted")
|
||||
/// Per-field provenance: the verbatim source span the value came from, an
|
||||
/// optional page index, and per-field confidence. Makes "extract what's
|
||||
/// written" auditable against the original. The source span is a verbatim
|
||||
/// excerpt of the clinical document (PHI), so it is stored encrypted with the
|
||||
/// same codec as `dataEncrypted`.
|
||||
provenanceEncrypted Bytes @map("provenance_encrypted")
|
||||
provenanceEncrypted Bytes @map("provenance_encrypted")
|
||||
/// The id of the structured-store row created when the fact was approved
|
||||
/// (LabResult / IllnessEpisode / Medication), or NULL while PENDING /
|
||||
/// REJECTED.
|
||||
committedRecordId String? @map("committed_record_id")
|
||||
committedRecordId String? @map("committed_record_id")
|
||||
/// The structured store the approved fact landed in ("labResult" /
|
||||
/// "illnessEpisode" / "medication"), or NULL.
|
||||
committedRecordType String? @map("committed_record_type")
|
||||
committedRecordType String? @map("committed_record_type")
|
||||
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.25 — documents library: owner-scoped metadata edit.
|
||||
*
|
||||
* Pins that PATCH is owner-scoped (a caller cannot touch another user's
|
||||
* document — the `where` carries the session userId, so a foreign row resolves
|
||||
* to a 404) and that an own-row edit sets only the sent fields (no mass
|
||||
* assignment).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findFirstOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn(() => new Uint8Array([1])),
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 239, resetAt: Date.now() }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkReq(id: string, body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}`),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
function docRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
kind: "DOCTOR_REPORT",
|
||||
title: "Renamed",
|
||||
filename: "x.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 70,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
errorReason: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00.000Z"),
|
||||
facts: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/documents/inbound/[id]", () => {
|
||||
it("cannot touch another user's document (404, no update)", async () => {
|
||||
// The foreign row does not satisfy `{ id, userId, deletedAt: null }`.
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("foreign-doc", { title: "hijack" }) as never,
|
||||
ctx("foreign-doc") as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
|
||||
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]!
|
||||
.where!;
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("edits only the sent fields on an owned document", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.inboundDocument.update).mockResolvedValue(
|
||||
docRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
|
||||
docRow({ kind: "IMAGING", title: "Renamed" }) as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { title: "Renamed", kind: "IMAGING" }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arg = vi.mocked(prisma.inboundDocument.update).mock.calls[0][0];
|
||||
expect(arg.where).toEqual({ id: "doc-1" });
|
||||
expect(arg.data.title).toBe("Renamed");
|
||||
expect(arg.data.kind).toBe("IMAGING");
|
||||
// documentDate was not sent → not present in the update payload.
|
||||
expect("documentDate" in arg.data).toBe(false);
|
||||
});
|
||||
|
||||
it("422s on an empty update", async () => {
|
||||
const res = await PATCH(mkReq("doc-1", {}) as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.25 — optional extraction action.
|
||||
*
|
||||
* Pins the store-first fix: with NO vision provider configured, extraction
|
||||
* 422s on the ENHANCEMENT only — the already-stored document is left intact
|
||||
* (no fact-staging transaction, no status flip). This is the inverse of the
|
||||
* old behaviour where the absence of a provider blocked the upload itself.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { deleteMany: vi.fn(), count: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn(() => new Uint8Array([1])),
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-27"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
}));
|
||||
vi.mock("@/lib/documents/extract", () => ({
|
||||
runInboundExtraction: vi.fn(),
|
||||
InboundExtractError: class InboundExtractError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, resetAt: Date.now() }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
function visionReq(id: string): NextRequest {
|
||||
// No JSON content-type → vision mode (operates on the stored original).
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}/extract`),
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
kind: "OTHER",
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
it("422s without a vision provider and leaves the stored row intact", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.providerUnsupported");
|
||||
|
||||
// The stored document is untouched — no staging transaction, no flip.
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("409s and refuses re-extraction when any fact is already APPROVED", async () => {
|
||||
// A partially-confirmed document stays at EXTRACTED, so the CONFIRMED gate
|
||||
// does not catch it; the approved-fact guard must.
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
kind: "OTHER",
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
mimeType: "image/png",
|
||||
status: "EXTRACTED",
|
||||
} as never);
|
||||
vi.mocked(prisma.extractedFact.count).mockResolvedValue(2 as never);
|
||||
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe(
|
||||
"documents.inbound.alreadyPartlyConfirmed",
|
||||
);
|
||||
|
||||
// No staging transaction, no fact deletion — committed provenance is safe.
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(prisma.extractedFact.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s for a document the caller does not own", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await POST(
|
||||
visionReq("foreign") as never,
|
||||
ctx("foreign") as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* v1.25 — optional AI extraction on an already-STORED document.
|
||||
*
|
||||
* Extraction is no longer part of upload: a document is stored first
|
||||
* (provider-free), and THIS route is the explicit, user-triggered enhancement.
|
||||
* It carries the entire ingest gauntlet that upload used to run inline —
|
||||
* resolve provider → assertConsentForChain → rate-limit → reserveBudget →
|
||||
* runInboundExtraction → reconcileSpend → stage facts — but now against a row
|
||||
* that already exists. Absent a provider this 422s the ENHANCEMENT only; the
|
||||
* stored document is untouched and remains filed.
|
||||
*
|
||||
* Two modes, dispatched on content-type (mirroring the original upload):
|
||||
* - VISION (no JSON body): decrypt the stored original, re-derive its MIME,
|
||||
* and run the vision-capable provider over it.
|
||||
* - TEXT (application/json, opt-in local OCR): `{ mode: "text", text }` — the
|
||||
* browser OCR'd the document locally and posts only the text to structure.
|
||||
*
|
||||
* The document is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside it. The staged facts land PENDING for the mandatory
|
||||
* review-then-confirm screen; nothing reaches the structured stores here.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
InboundExtractError,
|
||||
runInboundExtraction,
|
||||
type InboundExtractionResult,
|
||||
} from "@/lib/documents/extract";
|
||||
import {
|
||||
decryptDocumentFromBytes,
|
||||
encryptFactData,
|
||||
encryptFactProvenance,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */
|
||||
const EXTRACT_LIMIT_PER_HOUR = 6;
|
||||
const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/** A loaded, owner-scoped, still-extractable document row. */
|
||||
type LoadedDocument = {
|
||||
id: string;
|
||||
kind: string;
|
||||
contentEncrypted: Uint8Array;
|
||||
mimeType: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the document's staged facts and flip it to EXTRACTED in one
|
||||
* transaction. Re-extraction is allowed only when NO fact is APPROVED yet (the
|
||||
* POST handler refuses it otherwise), so clearing every prior staged fact here
|
||||
* can never drop an approved row or sever a committed-record provenance link —
|
||||
* the only facts present are PENDING / REJECTED leftovers from an earlier run.
|
||||
*/
|
||||
async function stageExtraction(
|
||||
documentId: string,
|
||||
userId: string,
|
||||
result: InboundExtractionResult,
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.extractedFact.deleteMany({ where: { documentId, userId } });
|
||||
await tx.inboundDocument.update({
|
||||
where: { id: documentId },
|
||||
data: {
|
||||
status: "EXTRACTED",
|
||||
providerType: result.providerType,
|
||||
reportDate: result.reportDate
|
||||
? new Date(`${result.reportDate}T00:00:00.000Z`)
|
||||
: null,
|
||||
facts: {
|
||||
create: result.facts.map((f) => ({
|
||||
userId,
|
||||
factType: f.factType,
|
||||
status: "PENDING" as const,
|
||||
confidence: f.confidence,
|
||||
needsReview: f.needsReview,
|
||||
dataEncrypted: encryptFactData(f.data),
|
||||
provenanceEncrypted: encryptFactProvenance(f.provenance),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
return tx.inboundDocument.findUniqueOrThrow({
|
||||
where: { id: documentId },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const document = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
contentEncrypted: true,
|
||||
mimeType: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
if (!document) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
if (document.status === "CONFIRMED") {
|
||||
return apiError("This document has already been confirmed.", 422, {
|
||||
errorCode: "documents.inbound.alreadyConfirmed",
|
||||
});
|
||||
}
|
||||
|
||||
// Refuse re-extraction once ANY fact on this document is APPROVED. A
|
||||
// partially-confirmed document (some facts approved, the rest still pending)
|
||||
// stays at status EXTRACTED, so the CONFIRMED gate above does not catch it.
|
||||
// Re-extracting would `deleteMany` the APPROVED rows — severing the
|
||||
// committed-record provenance link — and re-stage the same facts as PENDING,
|
||||
// letting the user approve them a second time and duplicate the committed
|
||||
// lab / condition / medication. Block it: the user must finish reviewing or
|
||||
// discard the document first.
|
||||
const approvedCount = await prisma.extractedFact.count({
|
||||
where: { documentId: document.id, userId: user.id, status: "APPROVED" },
|
||||
});
|
||||
if (approvedCount > 0) {
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractRefusedApproved" },
|
||||
meta: { documentId: document.id, approvedCount },
|
||||
});
|
||||
return apiError(
|
||||
"Some facts from this document are already confirmed. Finish reviewing or discard it before extracting again.",
|
||||
409,
|
||||
{ errorCode: "documents.inbound.alreadyPartlyConfirmed" },
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextExtract(request, user.id, document);
|
||||
}
|
||||
return handleVisionExtract(request, user.id, document);
|
||||
},
|
||||
);
|
||||
|
||||
/** TEXT mode — structure in-browser-OCR'd text against the stored row. */
|
||||
async function handleTextExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { labsLocalOcrEnabled: true },
|
||||
});
|
||||
if (!row?.labsLocalOcrEnabled) {
|
||||
annotate({ action: { name: "documents.inbound.providerUnsupported" } });
|
||||
return apiError("Local OCR is not enabled", 422, {
|
||||
errorCode: "documents.inbound.localOcrDisabled",
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many extractions. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
ocrText: parsed.data.text,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const updated = await stageExtraction(document.id, userId, result);
|
||||
|
||||
await auditLog("documents.inbound.extract", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: updated.facts.length,
|
||||
mode: "text",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "text" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** VISION mode — run the stored original through the vision provider. */
|
||||
async function handleVisionExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many extractions. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Decrypt the stored original and re-derive its MIME from the bytes (never
|
||||
// trust a stored label for the provider call).
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = decryptDocumentFromBytes(document.contentEncrypted);
|
||||
} catch {
|
||||
return apiError("Couldn't read the stored document.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const mime = detectOcrMimeType(buffer);
|
||||
if (!mime) {
|
||||
return apiError(
|
||||
"This document can't be scanned. Use local OCR (text mode).",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.fileType" },
|
||||
);
|
||||
}
|
||||
|
||||
if (mime === "application/pdf" && !pick.pdfSupported) {
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; use local OCR instead.",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
|
||||
);
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
const dataBase64 = buffer.toString("base64");
|
||||
const images =
|
||||
mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
|
||||
const documents =
|
||||
mime === "application/pdf"
|
||||
? [{ mediaType: "application/pdf" as const, dataBase64 }]
|
||||
: [];
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
images,
|
||||
documents,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const updated = await stageExtraction(document.id, userId, result);
|
||||
|
||||
await auditLog("documents.inbound.extract", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: updated.facts.length,
|
||||
mode: "vision",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "vision" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.25.1 (W-DOCS-IN) — `GET /api/documents/inbound/[id]/original`.
|
||||
*
|
||||
* The route decrypts and serves the raw uploaded document. Load-bearing
|
||||
* behaviour under test: it is owner-scoped (the query narrows on the session
|
||||
* userId, so another user's document is a 404), it returns the decrypted bytes
|
||||
* with the stored MIME type, and it fails closed on a decrypt error.
|
||||
*/
|
||||
|
||||
// A reversible crypto stub so encryptDocumentToBytes / decryptDocumentFromBytes
|
||||
// round-trip without real keys. `decrypt` is overridden per-test for the
|
||||
// fail-closed path.
|
||||
const decryptMock = vi.fn((s: string) =>
|
||||
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
|
||||
);
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`,
|
||||
decrypt: (s: string) => decryptMock(s),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 239, resetAt: Date.now() }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptDocumentToBytes } from "@/lib/documents/store";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
const callGet = GET as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: Ctx,
|
||||
) => Promise<Response>;
|
||||
|
||||
function makeReq(id: string): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}/original`),
|
||||
);
|
||||
}
|
||||
function ctx(id: string): Ctx {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decryptMock.mockImplementation((s: string) =>
|
||||
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound/[id]/original", () => {
|
||||
it("returns the decrypted original with the stored MIME type, owner-scoped", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("inline");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("report.pdf");
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
expect(out.equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
// The query is narrowed on the session userId + live rows only.
|
||||
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0]
|
||||
?.where as { id: string; userId: string; deletedAt: null };
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.id).toBe("doc-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("serves inline with a generated fallback name when filename is null", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-2",
|
||||
filename: null,
|
||||
mimeType: "image/png",
|
||||
contentEncrypted: encryptDocumentToBytes(Buffer.from("png bytes")),
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq("doc-2"), ctx("doc-2"));
|
||||
expect(res.status).toBe(200);
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
// The stored set is inline-safe by construction → always inline.
|
||||
expect(disposition).toContain("inline");
|
||||
// No filename → generated fallback keyed by id + extension.
|
||||
expect(disposition).toContain("document-doc-2.png");
|
||||
});
|
||||
|
||||
it("RFC 5987-encodes a non-ASCII filename and keeps an ASCII fallback", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-4",
|
||||
filename: "Müller-Bericht.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq("doc-4"), ctx("doc-4"));
|
||||
expect(res.status).toBe(200);
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
// ASCII fallback: the non-ASCII byte is replaced so the bare `filename=`
|
||||
// value is header-safe; the RFC 5987 form carries the percent-encoded UTF-8.
|
||||
expect(disposition).toContain(`filename="M_ller-Bericht.pdf"`);
|
||||
expect(disposition).toContain("filename*=UTF-8''M%C3%BCller-Bericht.pdf");
|
||||
});
|
||||
|
||||
it("404s for another user's document (the userId narrows it out)", async () => {
|
||||
// The owner-scoped where clause yields no row for a non-owner.
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
|
||||
const res = await callGet(makeReq("other-doc"), ctx("other-doc"));
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fails closed (500) on a decrypt error — never serves ciphertext", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-3",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("unknown key id");
|
||||
});
|
||||
|
||||
const res = await callGet(makeReq("doc-3"), ctx("doc-3"));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.headers.get("Content-Type")).toContain("application/json");
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* v1.25.1 (W-DOCS-IN) — view / download the original uploaded document.
|
||||
*
|
||||
* The raw doctor report / discharge letter is stored encrypted at rest
|
||||
* (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY
|
||||
* path that decrypts and serves it, so the user can re-read the source the
|
||||
* extracted facts came from. Owner-scoped + module-gated; the bytes are PHI:
|
||||
* never logged, never served cross-user, fail-closed on a decrypt error.
|
||||
*
|
||||
* The response carries the stored `mimeType`. Uploads are constrained at the
|
||||
* store path to the inline-safe set (JPEG / PNG / WebP / PDF, magic-byte
|
||||
* sniffed), so the document is always served `Content-Disposition: inline` for
|
||||
* in-tab rendering. The filename is sanitised before it reaches the header.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptDocumentFromBytes } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Decrypting + serving an original is CPU work on PHI. A generous per-user
|
||||
* ceiling is a backstop against self-DoS only (the route is owner-scoped, so
|
||||
* there is no cross-user abuse vector).
|
||||
*/
|
||||
const ORIGINAL_READ_LIMIT_PER_HOUR = 240;
|
||||
const ORIGINAL_READ_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Extension fallback when the stored filename is absent / unusable. */
|
||||
const MIME_EXTENSION: Record<string, string> = {
|
||||
"application/pdf": "pdf",
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
|
||||
/**
|
||||
* Percent-encode a UTF-8 string for an RFC 5987 `filename*` parameter. Builds
|
||||
* on `encodeURIComponent` and additionally escapes the characters it leaves but
|
||||
* RFC 5987's `attr-char` set forbids (`' ( ) *`).
|
||||
*/
|
||||
function encodeRfc5987(value: string): string {
|
||||
return encodeURIComponent(value).replace(
|
||||
/['()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a header-safe filename. Strips control characters (incl. CR/LF,
|
||||
* the header-injection vector), quotes, and path separators (path traversal),
|
||||
* caps the length, and falls back to a generated name keyed by the document
|
||||
* id + MIME type when nothing usable remains.
|
||||
*/
|
||||
function safeDownloadName(
|
||||
filename: string | null,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
): string {
|
||||
const ext = MIME_EXTENSION[mimeType] ?? "bin";
|
||||
const cleaned = (filename ?? "")
|
||||
// Strip control chars (incl. CR/LF — header injection), quotes, and path
|
||||
// separators (path traversal) before the value reaches the header.
|
||||
.replace(/[\u0000-\u001f"\\\/]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
return cleaned.length > 0 ? cleaned : `document-${id}.${ext}`;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-original:${user.id}`,
|
||||
ORIGINAL_READ_LIMIT_PER_HOUR,
|
||||
ORIGINAL_READ_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const document = await prisma.inboundDocument.findFirst({
|
||||
// Owner-scoped: the userId narrows the row so a cuid guess cannot reach
|
||||
// another user's document. A cookie session and a Bearer token both
|
||||
// resolve through requireAuth(); neither can cross the userId boundary.
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
mimeType: true,
|
||||
contentEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!document) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = decryptDocumentFromBytes(document.contentEncrypted);
|
||||
} catch {
|
||||
// Fail closed — never fall back to the raw ciphertext. The error reason
|
||||
// (bad / missing key id) is logged by the annotation, never the bytes.
|
||||
annotate({
|
||||
action: { name: "documents.inbound.original.decryptFailed" },
|
||||
meta: { documentId: document.id },
|
||||
});
|
||||
return apiError("Could not read the stored document", 500, {
|
||||
errorCode: "documents.inbound.decryptFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const downloadName = safeDownloadName(
|
||||
document.filename,
|
||||
document.id,
|
||||
document.mimeType,
|
||||
);
|
||||
// ASCII fallback for the bare `filename=` (replace any non-ASCII byte) plus
|
||||
// an RFC 5987 `filename*` that carries the real UTF-8 name to compliant
|
||||
// clients. The stored set is inline-safe by construction, so always inline.
|
||||
const asciiName = downloadName.replace(/[^ -~]/g, "_");
|
||||
const disposition = `inline; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`;
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.original.get" },
|
||||
meta: {
|
||||
documentId: document.id,
|
||||
mimeType: document.mimeType,
|
||||
bytes: bytes.byteLength,
|
||||
disposition: "inline",
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(bytes as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": document.mimeType,
|
||||
"Content-Length": String(bytes.byteLength),
|
||||
"Content-Disposition": disposition,
|
||||
// Private PHI — never cache in a shared / disk cache.
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,24 +1,58 @@
|
||||
/**
|
||||
* v1.25 (W-DOCS-IN) — inbound clinical document by id: detail + discard.
|
||||
* v1.25 — inbound clinical document by id: detail + metadata edit + discard.
|
||||
*
|
||||
* GET returns the document plus its staged facts for the review screen.
|
||||
* PATCH renames / recategorises / sets the user filing date (no mass
|
||||
* assignment; `userId` from the session feeds the `where`).
|
||||
* DELETE soft-deletes (tombstone) and marks the document DISCARDED — facts
|
||||
* already approved into the structured stores are independent rows and stay.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { documentUpdateSchema } from "@/lib/validations/inbound-documents";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Edit / discard are owner-scoped, so a generous per-user ceiling is just a
|
||||
* self-DoS backstop. PATCH and DELETE share one write bucket.
|
||||
*/
|
||||
const WRITE_LIMIT_PER_HOUR = 240;
|
||||
const WRITE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Shared 429 helper for the mutating routes. */
|
||||
async function enforceWriteRateLimit(userId: string): Promise<Response | null> {
|
||||
const rl = await checkRateLimit(
|
||||
`documents-write:${userId}`,
|
||||
WRITE_LIMIT_PER_HOUR,
|
||||
WRITE_WINDOW_MS,
|
||||
);
|
||||
if (rl.allowed) return null;
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -45,12 +79,85 @@ export const GET = apiHandler(
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceWriteRateLimit(user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = documentUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document update", 422, {
|
||||
errorCode: "documents.inbound.invalidUpdate",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
// 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 = {};
|
||||
if (parsed.data.title !== undefined) data.title = parsed.data.title;
|
||||
if (parsed.data.kind !== undefined) data.kind = parsed.data.kind;
|
||||
if (parsed.data.documentDate !== undefined) {
|
||||
data.documentDate = parsed.data.documentDate
|
||||
? new Date(`${parsed.data.documentDate}T00:00:00.000Z`)
|
||||
: null;
|
||||
}
|
||||
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
|
||||
const document = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: existing.id },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: existing.id,
|
||||
fields: Object.keys(data),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.update" },
|
||||
meta: { documentId: existing.id, fields: Object.keys(data).join(",") },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(document, document.facts));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceWriteRateLimit(user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await params;
|
||||
const document = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.25 — documents library: store-only upload + browsable list.
|
||||
*
|
||||
* Pins the store-first inversion: a file uploads + persists as a STORED row
|
||||
* with NO provider / consent / budget call on the path (a self-hoster with no
|
||||
* document-scan provider can still file documents), and the list honours the
|
||||
* `q` / `kind` filters with keyset pagination.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
extractedFact: {
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const post = POST as unknown as (r: Request) => Promise<Response>;
|
||||
const get = GET as unknown as (r: Request) => Promise<Response>;
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==";
|
||||
|
||||
function mkUpload(fields: Record<string, string> = {}) {
|
||||
const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
|
||||
const blobBytes = new Uint8Array(body.byteLength);
|
||||
blobBytes.set(body);
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"file",
|
||||
new Blob([blobBytes], { type: "image/png" }),
|
||||
"x.png",
|
||||
);
|
||||
for (const [k, v] of Object.entries(fields)) formData.append(k, v);
|
||||
return new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
function docRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
kind: "OTHER",
|
||||
title: null,
|
||||
filename: "x.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 70,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound (store-only)", () => {
|
||||
it("stores a file as STORED with no provider call and returns 201", async () => {
|
||||
vi.mocked(prisma.inboundDocument.create).mockResolvedValue(
|
||||
docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never,
|
||||
);
|
||||
|
||||
const res = await post(
|
||||
mkUpload({ title: "Blood panel", kind: "LAB_RESULT" }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.status).toBe("STORED");
|
||||
expect(body.data.kind).toBe("LAB_RESULT");
|
||||
expect(body.data.title).toBe("Blood panel");
|
||||
|
||||
// The row is created with status STORED, no provider, userId from session.
|
||||
const arg = vi.mocked(prisma.inboundDocument.create).mock.calls[0]![0]!;
|
||||
expect(arg.data.status).toBe("STORED");
|
||||
expect(arg.data.userId).toBe("user-1");
|
||||
expect(arg.data.kind).toBe("LAB_RESULT");
|
||||
expect("providerType" in arg.data).toBe(false);
|
||||
// No userId smuggled from the form is honoured — it comes from the session.
|
||||
expect(arg.data.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("rejects a non-image/pdf payload with 415", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", new Blob([new Uint8Array([1, 2, 3, 4])]), "x.bin");
|
||||
const req = new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const res = await post(req);
|
||||
expect(res.status).toBe(415);
|
||||
expect(prisma.inboundDocument.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound (list)", () => {
|
||||
it("applies q + kind filters and returns a keyset page with grouped counts", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
docRow({ id: "d1" }),
|
||||
docRow({ id: "d2" }),
|
||||
] as never);
|
||||
// factCount excludes REJECTED; pendingCount is the PENDING subset. The
|
||||
// counts come from a single grouped query, not from materialised fact rows.
|
||||
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([
|
||||
{ documentId: "d2", status: "PENDING", _count: { _all: 1 } },
|
||||
{ documentId: "d2", status: "APPROVED", _count: { _all: 1 } },
|
||||
{ documentId: "d2", status: "REJECTED", _count: { _all: 3 } },
|
||||
] as never);
|
||||
|
||||
const res = await get(
|
||||
new Request(
|
||||
"http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&sort=createdAt&order=desc&limit=10",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.documents).toHaveLength(2);
|
||||
expect(body.data.nextCursor).toBeNull();
|
||||
|
||||
// d1 has no facts; d2 has 1 pending + 1 approved + 3 rejected → factCount 2
|
||||
// (rejected excluded), pendingCount 1.
|
||||
const [d1, d2] = body.data.documents;
|
||||
expect(d1.factCount).toBe(0);
|
||||
expect(d1.pendingCount).toBe(0);
|
||||
expect(d2.factCount).toBe(2);
|
||||
expect(d2.pendingCount).toBe(1);
|
||||
|
||||
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!;
|
||||
const where = arg.where!;
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(where.kind).toBe("LAB_RESULT");
|
||||
expect(where.OR).toEqual([
|
||||
{ title: { contains: "panel", mode: "insensitive" } },
|
||||
{ filename: { contains: "panel", mode: "insensitive" } },
|
||||
]);
|
||||
// No fact rows are materialised in the page query — counts come from groupBy.
|
||||
expect(arg.include).toBeUndefined();
|
||||
// limit+1 fetched for the has-more probe.
|
||||
expect(arg.take).toBe(11);
|
||||
});
|
||||
|
||||
it("emits nextCursor when a full page+1 is returned", async () => {
|
||||
const rows = Array.from({ length: 3 }, (_, i) => ({
|
||||
...docRow({ id: `d${i}` }),
|
||||
_count: { facts: 0 },
|
||||
facts: [],
|
||||
}));
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never);
|
||||
|
||||
const res = await get(
|
||||
new Request("http://localhost/api/documents/inbound?limit=2"),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.documents).toHaveLength(2);
|
||||
expect(body.data.nextCursor).toBe("d1");
|
||||
});
|
||||
|
||||
it("422s on a bad sort value", async () => {
|
||||
const res = await get(
|
||||
new Request("http://localhost/api/documents/inbound?sort=nope"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,31 @@
|
||||
/**
|
||||
* v1.25 (W-DOCS-IN) — inbound clinical documents: upload + list.
|
||||
* v1.25 — documents library: store-first upload + browsable list.
|
||||
*
|
||||
* POST ingests an uploaded doctor report / discharge letter through the
|
||||
* EXISTING dedicated OCR/vision provider rails (the v1.22 document-scan
|
||||
* provider), stores the raw document ENCRYPTED at rest, and stages the
|
||||
* extracted STRUCTURED FACTS for a mandatory review-then-confirm screen.
|
||||
* Nothing reaches the structured stores here — the confirm route is the only
|
||||
* write path, and a low-confidence fact fails closed.
|
||||
* POST is STORE-ONLY and provider-free. A self-hoster uploads any clinical
|
||||
* document; it is stored ENCRYPTED at rest with `status: STORED` and NO
|
||||
* extraction run. There is no provider resolution, no AI consent guard, no
|
||||
* budget, and NO egress on this path — a file can always be filed, even on an
|
||||
* account with no document-scan provider configured. Optional AI extraction is
|
||||
* a separate, user-triggered action (`POST /api/documents/inbound/[id]/extract`).
|
||||
*
|
||||
* Two modes, dispatched on the request content-type (mirroring the Lab-OCR
|
||||
* extract route):
|
||||
* - VISION (multipart/form-data): a photo / PDF run through the vision-capable
|
||||
* provider; the original is stored encrypted.
|
||||
* - TEXT (application/json, opt-in local OCR): the browser OCR's the image
|
||||
* and POSTs only the extracted text; the text is stored encrypted (the raw
|
||||
* image never reaches the server).
|
||||
* GET lists the caller's documents with title/filename search, a category
|
||||
* filter, a `documentDate` range, sort, and keyset pagination — all through the
|
||||
* parameter-bound Prisma query builder (no raw SQL).
|
||||
*
|
||||
* Guards mirror the Lab-OCR discipline: requireAuth → module gate → resolve
|
||||
* provider → assertConsentForChain → rate-limit → reserveBudget → extract →
|
||||
* reconcile budget. The document is UNTRUSTED (prompt-injection): the server
|
||||
* never acts on an instruction inside it — the review step is the safety
|
||||
* boundary, and "extract, never interpret" is enforced end-to-end.
|
||||
* The document is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside it. Storing it does nothing with its contents; the later
|
||||
* review-then-confirm step is the safety boundary for any extracted facts.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
InboundExtractError,
|
||||
runInboundExtraction,
|
||||
type StagedFactInput,
|
||||
} from "@/lib/documents/extract";
|
||||
import {
|
||||
encryptDocumentToBytes,
|
||||
encryptFactData,
|
||||
encryptFactProvenance,
|
||||
serialiseDocument,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import {
|
||||
BodyTooLargeError,
|
||||
detectOcrMimeType,
|
||||
@@ -66,60 +36,26 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
inboundTextExtractSchema,
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type InboundDocumentKindValue,
|
||||
documentCreateSchema,
|
||||
documentListQuerySchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */
|
||||
const EXTRACT_LIMIT_PER_HOUR = 6;
|
||||
const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
/**
|
||||
* A store-only upload touches no provider — the only abuse vector is disk, so
|
||||
* a generous per-user ceiling is enough (extraction has its own tight gate).
|
||||
*/
|
||||
const UPLOAD_LIMIT_PER_HOUR = 60;
|
||||
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Persist the document + its staged facts and return the list DTO. */
|
||||
async function persistExtraction(args: {
|
||||
userId: string;
|
||||
kind: InboundDocumentKindValue;
|
||||
filename: string | null;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
contentEncrypted: Uint8Array<ArrayBuffer>;
|
||||
providerType: string;
|
||||
reportDate: string | null;
|
||||
facts: StagedFactInput[];
|
||||
}) {
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: args.userId,
|
||||
kind: args.kind,
|
||||
filename: args.filename,
|
||||
mimeType: args.mimeType,
|
||||
byteSize: args.byteSize,
|
||||
contentEncrypted: args.contentEncrypted,
|
||||
status: "EXTRACTED",
|
||||
providerType: args.providerType,
|
||||
reportDate: args.reportDate
|
||||
? new Date(`${args.reportDate}T00:00:00.000Z`)
|
||||
: null,
|
||||
facts: {
|
||||
create: args.facts.map((f) => ({
|
||||
userId: args.userId,
|
||||
factType: f.factType,
|
||||
status: "PENDING" as const,
|
||||
confidence: f.confidence,
|
||||
needsReview: f.needsReview,
|
||||
dataEncrypted: encryptFactData(f.data),
|
||||
provenanceEncrypted: encryptFactProvenance(f.provenance),
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { facts: true },
|
||||
});
|
||||
return document;
|
||||
/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
|
||||
function isoDateToUtc(value: string): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** POST — store an uploaded document encrypted at rest. No extraction. */
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -128,42 +64,10 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextUpload(request, user.id);
|
||||
}
|
||||
return handleVisionUpload(request, user.id);
|
||||
});
|
||||
|
||||
/** TEXT mode — structure in-browser-OCR'd text; store the text encrypted. */
|
||||
async function handleTextUpload(
|
||||
request: Request,
|
||||
userId: string,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { labsLocalOcrEnabled: true },
|
||||
});
|
||||
if (!row?.labsLocalOcrEnabled) {
|
||||
annotate({ action: { name: "documents.inbound.providerUnsupported" } });
|
||||
return apiError("Local OCR is not enabled", 422, {
|
||||
errorCode: "documents.inbound.localOcrDisabled",
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
`documents-upload:${user.id}`,
|
||||
UPLOAD_LIMIT_PER_HOUR,
|
||||
UPLOAD_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many uploads. Try again later.", 429, {
|
||||
@@ -175,304 +79,210 @@ async function handleTextUpload(
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > OCR_MAX_BYTES) {
|
||||
return apiError("File is too large (max 12 MB).", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
});
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
ocrText: parsed.data.text,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const textBytes = Buffer.from(parsed.data.text, "utf8");
|
||||
const document = await persistExtraction({
|
||||
userId,
|
||||
kind: parsed.data.kind ?? result.kind,
|
||||
filename: null,
|
||||
mimeType: "text/plain",
|
||||
byteSize: textBytes.byteLength,
|
||||
contentEncrypted: encryptDocumentToBytes(textBytes),
|
||||
providerType: result.providerType,
|
||||
reportDate: result.reportDate,
|
||||
facts: result.facts,
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: document.facts.length,
|
||||
mode: "text",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
serialiseDocument(document, {
|
||||
factCount: document.facts.length,
|
||||
pendingCount: document.facts.length,
|
||||
}),
|
||||
201,
|
||||
);
|
||||
const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "text" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** VISION mode — run a photo / PDF through the vision provider; store it. */
|
||||
async function handleVisionUpload(
|
||||
request: Request,
|
||||
userId: string,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many uploads. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > OCR_MAX_BYTES) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
return apiError("File is too large (max 12 MB).", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
});
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
return apiError("File is too large (max 12 MB).", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
});
|
||||
}
|
||||
return apiError("Invalid multipart body", 400);
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
// Optional document-kind label (a label only; drives no interpretation).
|
||||
const kindRaw = formData.get("kind");
|
||||
const kind: InboundDocumentKindValue =
|
||||
typeof kindRaw === "string" &&
|
||||
(INBOUND_DOCUMENT_KINDS as readonly string[]).includes(kindRaw)
|
||||
? (kindRaw as InboundDocumentKindValue)
|
||||
: "OTHER";
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(await file.arrayBuffer());
|
||||
} catch {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError("Failed to read uploaded file", 400);
|
||||
}
|
||||
|
||||
const mime = detectOcrMimeType(buffer);
|
||||
if (!mime) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
|
||||
errorCode: "documents.inbound.fileType",
|
||||
});
|
||||
}
|
||||
|
||||
if (mime === "application/pdf" && !pick.pdfSupported) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; upload a photo instead.",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
|
||||
);
|
||||
}
|
||||
|
||||
const dataBase64 = buffer.toString("base64");
|
||||
const images =
|
||||
mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
|
||||
const documents =
|
||||
mime === "application/pdf"
|
||||
? [{ mediaType: "application/pdf" as const, dataBase64 }]
|
||||
: [];
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
images,
|
||||
documents,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const document = await persistExtraction({
|
||||
userId,
|
||||
kind,
|
||||
filename:
|
||||
typeof file.name === "string" ? file.name.slice(0, 255) : null,
|
||||
mimeType: mime,
|
||||
byteSize: buffer.byteLength,
|
||||
contentEncrypted: encryptDocumentToBytes(buffer),
|
||||
providerType: result.providerType,
|
||||
reportDate: result.reportDate,
|
||||
facts: result.facts,
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: document.facts.length,
|
||||
mode: "vision",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
serialiseDocument(document, {
|
||||
factCount: document.facts.length,
|
||||
pendingCount: document.facts.length,
|
||||
}),
|
||||
201,
|
||||
);
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError(
|
||||
"Couldn't read the document. Try a clearer copy.",
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
},
|
||||
);
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "vision" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
|
||||
() => {},
|
||||
);
|
||||
throw err;
|
||||
return apiError("Invalid multipart body", 400);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET — list the caller's inbound documents (newest first, live only). */
|
||||
export const GET = apiHandler(async () => {
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
// Optional metadata (title / kind / documentDate). The file is read
|
||||
// separately; these are the form fields beside it.
|
||||
const parsed = documentCreateSchema.safeParse({
|
||||
title: formData.get("title") ?? undefined,
|
||||
kind: formData.get("kind") ?? undefined,
|
||||
documentDate: formData.get("documentDate") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document metadata", 422, {
|
||||
errorCode: "documents.inbound.invalidMetadata",
|
||||
});
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(await file.arrayBuffer());
|
||||
} catch {
|
||||
return apiError("Failed to read uploaded file", 400);
|
||||
}
|
||||
|
||||
const mime = detectOcrMimeType(buffer);
|
||||
if (!mime) {
|
||||
return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
|
||||
errorCode: "documents.inbound.fileType",
|
||||
});
|
||||
}
|
||||
|
||||
// No mass assignment — every column is set field-by-field; `userId` comes
|
||||
// from the session, never the body. `status` is STORED (the library default);
|
||||
// no provider / consent / budget call runs here.
|
||||
//
|
||||
// `documentDate` is the effective filing date the library sorts, groups, and
|
||||
// range-filters on. When the user does not supply one at upload, default it to
|
||||
// the upload day (now) — the same effective date the UI shows — so the column
|
||||
// is never NULL on a stored row. This keeps display == sort == filter: an
|
||||
// undated upload no longer sinks below older dated rows under `nulls: "last"`,
|
||||
// and a "this month" range filter sees it. (At store time `reportDate` is
|
||||
// still null; it only exists after an extraction, so the default resolves to
|
||||
// the upload day.) The date stays user-editable via PATCH, which still allows
|
||||
// clearing it back to NULL.
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: parsed.data.kind ?? "OTHER",
|
||||
// Stored plaintext on purpose (mirrors `filename`) so the list can
|
||||
// ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that is the
|
||||
// accepted tradeoff for server-side search/sort — the document body stays
|
||||
// encrypted.
|
||||
title: parsed.data.title ?? null,
|
||||
filename: typeof file.name === "string" ? file.name.slice(0, 255) : null,
|
||||
mimeType: mime,
|
||||
byteSize: buffer.byteLength,
|
||||
contentEncrypted: encryptDocumentToBytes(buffer),
|
||||
status: "STORED",
|
||||
documentDate: parsed.data.documentDate
|
||||
? isoDateToUtc(parsed.data.documentDate)
|
||||
: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.store", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: document.id, mime },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.store" },
|
||||
meta: { documentId: document.id, byteSize: document.byteSize },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
serialiseDocument(document, { factCount: 0, pendingCount: 0 }),
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
/** GET — list the caller's documents (search / filter / sort / paginate). */
|
||||
export const GET = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const documents = await prisma.inboundDocument.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
_count: { select: { facts: true } },
|
||||
facts: { where: { status: "PENDING" }, select: { id: true } },
|
||||
},
|
||||
take: 200,
|
||||
const url = new URL(request.url);
|
||||
const parsed = documentListQuerySchema.safeParse(
|
||||
Object.fromEntries(url.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid list query", 422, {
|
||||
errorCode: "documents.inbound.invalidQuery",
|
||||
});
|
||||
}
|
||||
const { q, kind, from, to, sort, order, cursor, limit } = parsed.data;
|
||||
|
||||
const where: Prisma.InboundDocumentWhereInput = {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
};
|
||||
if (kind) where.kind = kind;
|
||||
if (from || to) {
|
||||
where.documentDate = {
|
||||
...(from ? { gte: isoDateToUtc(from) } : {}),
|
||||
// inclusive end-of-day for the `to` bound
|
||||
...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}),
|
||||
};
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ filename: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Keyset pagination on the sort column + a stable `id` tiebreak; nullable
|
||||
// sort columns sort nulls last so undated documents trail.
|
||||
const primaryOrder:
|
||||
Prisma.InboundDocumentOrderByWithRelationInput | undefined =
|
||||
sort === "documentDate"
|
||||
? { documentDate: { sort: order, nulls: "last" } }
|
||||
: sort === "title"
|
||||
? { title: { sort: order, nulls: "last" } }
|
||||
: { createdAt: order };
|
||||
const orderBy: Prisma.InboundDocumentOrderByWithRelationInput[] = [
|
||||
primaryOrder,
|
||||
{ id: order },
|
||||
];
|
||||
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: limit + 1,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
|
||||
|
||||
// Fact tallies for the whole page in ONE grouped count — no per-document
|
||||
// fan-out and no materialising fact-id rows. `factCount` counts every
|
||||
// non-REJECTED fact (a rejected fact is discarded, not part of the document's
|
||||
// tally), `pendingCount` the PENDING subset still awaiting review.
|
||||
const factCounts = new Map<
|
||||
string,
|
||||
{ factCount: number; pendingCount: number }
|
||||
>();
|
||||
if (page.length > 0) {
|
||||
const groups = await prisma.extractedFact.groupBy({
|
||||
by: ["documentId", "status"],
|
||||
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
for (const g of groups) {
|
||||
const entry = factCounts.get(g.documentId) ?? {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
};
|
||||
const n = g._count._all;
|
||||
if (g.status !== "REJECTED") entry.factCount += n;
|
||||
if (g.status === "PENDING") entry.pendingCount += n;
|
||||
factCounts.set(g.documentId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.list" },
|
||||
meta: { count: documents.length },
|
||||
meta: { count: page.length, sort, order, filtered: Boolean(q || kind) },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
documents: documents.map((doc) =>
|
||||
serialiseDocument(doc, {
|
||||
factCount: doc._count.facts,
|
||||
pendingCount: doc.facts.length,
|
||||
}),
|
||||
documents: page.map((doc) =>
|
||||
serialiseDocument(
|
||||
doc,
|
||||
factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 },
|
||||
),
|
||||
),
|
||||
nextCursor,
|
||||
});
|
||||
});
|
||||
@@ -164,4 +164,68 @@ describe("GET /api/insights/cards", () => {
|
||||
expect(body.data[0].provider).toBe("anthropic");
|
||||
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag a fully-adherent weekly injectable as low compliance (#214 regression)", async () => {
|
||||
// Pre-fix this path used a naive `schedules.length × 7` denominator, so a
|
||||
// weekly Monday injectable with its one dose taken computed ~14% and fired
|
||||
// a false "Low compliance" warning. The cadence-aware engine counts only
|
||||
// the Monday in the window → 100%, no alert.
|
||||
vi.useFakeTimers();
|
||||
// Pin NOW to a Wednesday so the trailing 7-day window holds exactly one
|
||||
// Monday (the prior 2025-01-13).
|
||||
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
||||
try {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "med-weekly",
|
||||
name: "Weekly injectable",
|
||||
dose: null,
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
oneShot: false,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
createdAt: new Date("2024-06-01T00:00:00Z"),
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-mon",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "10:00",
|
||||
daysOfWeek: "1",
|
||||
timesOfDay: ["08:30"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
medicationId: "med-weekly",
|
||||
scheduledFor: new Date("2025-01-13T08:30:00Z"),
|
||||
takenAt: new Date("2025-01-13T08:35:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ title: string }>;
|
||||
};
|
||||
expect(body.data.some((c) => c.title.startsWith("Low compliance"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
type HealthAlert,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
type Severity = "alert" | "caution" | "info" | "good";
|
||||
@@ -86,11 +92,17 @@ export const GET = apiHandler(async () => {
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id, active: true },
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId: user.id, active: true, asNeeded: false },
|
||||
include: {
|
||||
schedules: true,
|
||||
// Schedules through the shared compliance select so the configured
|
||||
// per-dose windows reach the engine like every other surface.
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware expected counts.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -135,7 +147,12 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight medication compliance for alert input.
|
||||
// Medication compliance for alert input — routed through the canonical
|
||||
// cadence-aware engine (`calculateCompliance`), the same single source of
|
||||
// truth the comprehensive insights, dashboard pillar, Coach snapshot and
|
||||
// doctor report use. The naive `schedules.length × days` denominator was a
|
||||
// #214 regression that falsely flagged weekly injectables and paused meds
|
||||
// as low-adherence on this user-facing alert path.
|
||||
const medicationCompliance: Array<{
|
||||
name: string;
|
||||
compliance7: number;
|
||||
@@ -150,8 +167,11 @@ export const GET = apiHandler(async () => {
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
deletedAt: null,
|
||||
medicationId: { in: activeMeds.map((m) => m.id) },
|
||||
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
// 90-day window: the rolling-cadence gap-walk re-anchors on prior
|
||||
// intakes outside the 30-day rate window.
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
@@ -165,26 +185,36 @@ export const GET = apiHandler(async () => {
|
||||
arr.push(e);
|
||||
eventsByMed.set(e.medicationId, arr);
|
||||
}
|
||||
const cutoff7 = Date.now() - 7 * 86_400_000;
|
||||
const tz = user.timezone || "Europe/Berlin";
|
||||
for (const med of activeMeds) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const takenLast7 = events.filter(
|
||||
(e) =>
|
||||
e.takenAt !== null &&
|
||||
!e.skipped &&
|
||||
e.scheduledFor.getTime() >= cutoff7,
|
||||
).length;
|
||||
const taken30 = events.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
const expected7 = med.schedules.length * 7;
|
||||
const expected30 = med.schedules.length * 30;
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
// v1.7.0 SB-SCHED-2 — engine-routed denominator.
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
med,
|
||||
lastNonSkippedTakenAt(mapped),
|
||||
tz,
|
||||
);
|
||||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
|
||||
medicationContext,
|
||||
});
|
||||
const c30 = calculateCompliance(
|
||||
mapped,
|
||||
med.schedules,
|
||||
30,
|
||||
med.createdAt,
|
||||
{
|
||||
medicationContext,
|
||||
},
|
||||
);
|
||||
medicationCompliance.push({
|
||||
name: med.name,
|
||||
compliance7:
|
||||
expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
|
||||
compliance30:
|
||||
expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -80,6 +81,28 @@ export default function LoginPage() {
|
||||
// can ride in parallel with the route transition + page-chunk
|
||||
// download instead of queueing behind the dashboard mount.
|
||||
function navigateAfterLogin() {
|
||||
// Start the new session from an empty in-memory cache. The root
|
||||
// QueryClient is created once and outlives every client-side navigation,
|
||||
// so a previous user who closed the tab WITHOUT logging out (no logout
|
||||
// fired) leaves their non-user-scoped health-data queries
|
||||
// (`["measurements"]`, `["dashboard","snapshot"]`, …) sitting in memory.
|
||||
// Clearing here — before any prefetch warms THIS user's data and before we
|
||||
// navigate — guarantees a fresh login never inherits another account's
|
||||
// cached entries. Logout / expiry clear the same cache from their own
|
||||
// boundaries; this covers the no-logout path. `clear()` also drops the
|
||||
// stale `["auth","me"]`, so the shell refetches it fresh on mount and the
|
||||
// former explicit `invalidateQueries({ queryKey: auth() })` is redundant.
|
||||
queryClient.clear();
|
||||
// Belt-and-suspenders to match the session-END wipe (logout / 401-expiry):
|
||||
// also drop the OFFLINE layers — the IndexedDB query snapshot and the SW
|
||||
// data/page caches — so a previous account's persisted dashboard never
|
||||
// lingers on disk for the new session. The restore is already per-account
|
||||
// bound, but a fresh login on a shared browser should leave nothing of the
|
||||
// prior account behind. Order is clear → prefetch → navigate: the wipe is
|
||||
// started here (best-effort, like logout) before the prefetch warms THIS
|
||||
// user's data, and the persister's 1 s debounce gives the single IDB delete
|
||||
// ample headroom to land before the new snapshot is written.
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
const target = getRedirectTarget();
|
||||
if (target === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
@@ -105,7 +128,8 @@ export default function LoginPage() {
|
||||
credential,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user guard) and
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
@@ -150,7 +174,8 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user guard) and
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -193,7 +218,8 @@ export default function LoginPage() {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user guard)
|
||||
// and navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch {
|
||||
// Autofill aborts (no passkey, user dismisses, password path taken)
|
||||
@@ -228,10 +254,10 @@ export default function LoginPage() {
|
||||
<MfaLoginStep
|
||||
mfaTicket={mfaChallenge.ticket}
|
||||
methods={mfaChallenge.methods}
|
||||
onSuccess={async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.auth(),
|
||||
});
|
||||
onSuccess={() => {
|
||||
// `navigateAfterLogin` clears the in-memory cache (cross-user
|
||||
// guard) and navigates; the shell refetches `["auth","me"]`
|
||||
// fresh on mount.
|
||||
navigateAfterLogin();
|
||||
}}
|
||||
onCancel={() => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.1 — `/labs/[biomarkerId]/values`.
|
||||
*
|
||||
* The "show all readings" sub-page for one biomarker, mirroring the metric
|
||||
* sub-pages' `/insights/values/[type]`. The detail page keeps the
|
||||
* numbers-first spine (description → stat strip → chart → assessment) and
|
||||
* links here for the raw, editable reading feed. Owns auth + the back link to
|
||||
* the biomarker detail, and hands the marker id to `<LabValuesList>`.
|
||||
*/
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { LabValuesList } from "@/components/labs/lab-values-list";
|
||||
import type { BiomarkerDto } from "@/components/labs/types";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function LabBiomarkerValuesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ biomarkerId: string }>;
|
||||
}) {
|
||||
const { biomarkerId } = use(params);
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
const { data: marker } = useQuery({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
|
||||
enabled: isAuthenticated && biomarkerId.length > 0,
|
||||
});
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markerName = marker?.name ?? "";
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("labs.detail.valuesTitle", { marker: markerName })}
|
||||
description={t("labs.detail.valuesDescription")}
|
||||
backLink={
|
||||
<BackLink
|
||||
href={`/labs/${biomarkerId}`}
|
||||
label={t("labs.detail.backToMarker", { marker: markerName })}
|
||||
dataSlot="lab-values-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LabValuesList biomarkerId={biomarkerId} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react";
|
||||
|
||||
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
|
||||
import { LabForm } from "@/components/labs/lab-form";
|
||||
import { LabList } from "@/components/labs/lab-list";
|
||||
import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog";
|
||||
@@ -174,11 +173,6 @@ export default function LabsPage() {
|
||||
) : null}
|
||||
|
||||
<LabList onAddFirst={() => setDialogOpen(true)} />
|
||||
|
||||
{/* v1.25 — calm, context-specific framing for lab values: your own
|
||||
records, ranges vary by lab and person, only a clinician interprets
|
||||
them. One footer line, not a per-row banner. */}
|
||||
<MedicalDisclaimer variant="labs" className="px-1 pt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
@@ -525,12 +524,6 @@ export default function MedicationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.25 — calm framing for the medications surface: this tracks what
|
||||
you log and your own adherence, it doesn't assess dosing or
|
||||
interactions and isn't medical advice. One footer line, neutral —
|
||||
the med cards themselves stay un-tinted. */}
|
||||
<MedicalDisclaimer variant="medications" className="px-1 pt-2" />
|
||||
|
||||
{/* v1.5.5 — the per-card kebab triggers used to open inline
|
||||
IntakeImportDialog / ApiEndpointDialog mounts here, but both
|
||||
state slots were only ever set from inside the dialogs
|
||||
|
||||
@@ -8,7 +8,8 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
|
||||
/**
|
||||
* v1.25.0 — `/insights/mental-wellbeing`.
|
||||
* `/mental-wellbeing` — standalone module page (moved off `/insights/*` in
|
||||
* v1.25.1 so it no longer borrows the Insights tab strip / layout shell).
|
||||
*
|
||||
* Opt-in PHQ-9 / GAD-7 screener surface, beside mood tracking. The screen owns
|
||||
* its own questionnaire + result + crisis-resource flow; it deliberately does
|
||||
@@ -15,35 +15,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* The copy lives under the `selfDescription` i18n namespace so every locale
|
||||
* carries the same set; pick the note that fits the surface:
|
||||
*
|
||||
* - `intendedPurpose` — the canonical "what this is / is not, not a medical
|
||||
* device" line. Reuse verbatim; do not paraphrase per surface.
|
||||
* - `coach` — the Coach standing line (works from your data, not a doctor).
|
||||
* - `insights` — descriptive-patterns framing for insights + the briefing.
|
||||
* - `labs` — your own records, ranges vary, only a clinician interprets.
|
||||
* - `medications` — tracks what you log, not dosing/interaction advice.
|
||||
* - `emergency` — the red-flag interstitial note (contact emergency services).
|
||||
* - `dataPosture` — self-hosted, data stays on your instance, BYOK / local AI.
|
||||
*
|
||||
* The acknowledged onboarding disclaimer (`onboarding.disclaimer.*`) and the
|
||||
* legal medical-device boundary on the public privacy page remain the standing
|
||||
* floor; these notes are the gentle, in-context layer above it.
|
||||
* floor; this note is the gentle, in-context layer above it.
|
||||
*/
|
||||
export type DisclaimerVariant =
|
||||
| "intendedPurpose"
|
||||
| "coach"
|
||||
| "insights"
|
||||
| "labs"
|
||||
| "medications"
|
||||
| "emergency"
|
||||
| "dataPosture";
|
||||
export type DisclaimerVariant = "dataPosture";
|
||||
|
||||
const VARIANT_KEY: Record<DisclaimerVariant, string> = {
|
||||
intendedPurpose: "selfDescription.intendedPurpose",
|
||||
coach: "selfDescription.coach",
|
||||
insights: "selfDescription.insights",
|
||||
labs: "selfDescription.labs",
|
||||
medications: "selfDescription.medications",
|
||||
emergency: "selfDescription.emergency",
|
||||
dataPosture: "selfDescription.dataPosture",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
ExtractedFactDto,
|
||||
InboundDocumentDetailDto,
|
||||
InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* v1.25 — the Documents library.
|
||||
*
|
||||
* The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and
|
||||
* assert the static markup. Interaction-driven paths (the exact request params,
|
||||
* the provider-unsupported classification) are covered by the pure-helper unit
|
||||
* tests in `library-utils.test.ts`; here we assert the rendered library, the
|
||||
* detail/review surface, the edit form, and the inline provider note.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
AlreadyConfirmedNote,
|
||||
DocumentDetail,
|
||||
DocumentMetaEditForm,
|
||||
InboundDocumentsView,
|
||||
ProviderUnsupportedNote,
|
||||
} from "../inbound-documents-view";
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
over: Partial<InboundDocumentDto> = {},
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id,
|
||||
kind: "LAB_RESULT",
|
||||
title: null,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2026-06-20",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
createdAt: "2026-06-20T08:00:00.000Z",
|
||||
updatedAt: "2026-06-20T08:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
seed?.(queryClient);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<InboundDocumentsView> library", () => {
|
||||
it("renders the grouped list with titles, badges, and load-more", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{
|
||||
pages: [
|
||||
{
|
||||
documents: [
|
||||
doc("d1", {
|
||||
title: "Cardiology report",
|
||||
documentDate: "2026-06-20",
|
||||
pendingCount: 2,
|
||||
factCount: 3,
|
||||
}),
|
||||
doc("d2", {
|
||||
title: "Blood panel",
|
||||
documentDate: "2026-06-20",
|
||||
}),
|
||||
],
|
||||
nextCursor: "cursor-next",
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Library + toolbar shells render.
|
||||
expect(html).toContain('data-slot="documents-library"');
|
||||
expect(html).toContain('data-slot="documents-toolbar"');
|
||||
// Date group header + rows.
|
||||
expect(html).toContain('data-slot="documents-date-group"');
|
||||
expect(html).toContain('data-slot="documents-row"');
|
||||
// Titles surface (title preferred over filename).
|
||||
expect(html).toContain("Cardiology report");
|
||||
expect(html).toContain("Blood panel");
|
||||
// Kind + status badges from the EN bundle.
|
||||
expect(html).toContain("Lab result");
|
||||
expect(html).toContain("Stored");
|
||||
// pendingCount badge.
|
||||
expect(html).toContain("2 to review");
|
||||
// nextCursor → load-more button.
|
||||
expect(html).toContain("Load more");
|
||||
// A11Y-M2: the row's aria-controls points at the detail region id.
|
||||
expect(html).toContain('aria-controls="documents-detail-d1"');
|
||||
// QoL: accepted formats + size ceiling are shown by the file field.
|
||||
expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB");
|
||||
});
|
||||
|
||||
it("disables the upload button and shows a hint until a file is chosen", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
// A11Y-M4: with no file staged, an inline hint explains why upload is
|
||||
// disabled (the hint only renders on the !hasFile / not-pending path).
|
||||
expect(html).toContain("Choose a file to upload.");
|
||||
});
|
||||
|
||||
it("renders the document-date range filter inputs", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
// PERF-L: from/to date inputs are wired into the toolbar.
|
||||
expect(html).toContain('id="doc-from"');
|
||||
expect(html).toContain('id="doc-to"');
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no documents", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
expect(html).toContain("No documents yet.");
|
||||
expect(html).not.toContain('data-slot="documents-row"');
|
||||
});
|
||||
});
|
||||
|
||||
function fact(id: string): ExtractedFactDto {
|
||||
return {
|
||||
id,
|
||||
factType: "OBSERVATION",
|
||||
status: "PENDING",
|
||||
confidence: 0.9,
|
||||
needsReview: false,
|
||||
data: {
|
||||
label: "LDL Cholesterol",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 95,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2026-06-20",
|
||||
},
|
||||
provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 },
|
||||
committedRecordId: null,
|
||||
committedRecordType: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<DocumentDetail>", () => {
|
||||
function detail(over: Partial<InboundDocumentDetailDto> = {}) {
|
||||
return {
|
||||
...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }),
|
||||
facts: [fact("f1")],
|
||||
...over,
|
||||
} as InboundDocumentDetailDto;
|
||||
}
|
||||
|
||||
it("renders the original link, edit + extract controls, and the review facts", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-detail"');
|
||||
// Original view link points at the raw-bytes route.
|
||||
expect(html).toContain("/api/documents/inbound/d1/original");
|
||||
expect(html).toContain('data-slot="documents-edit-trigger"');
|
||||
// Extract button is present (status is not CONFIRMED).
|
||||
expect(html).toContain('data-slot="documents-extract"');
|
||||
expect(html).toContain("Extract facts");
|
||||
// The staged fact's transcription summary renders for review.
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
expect(html).toContain("Save approved facts");
|
||||
});
|
||||
|
||||
it("hides the extract button once the document is confirmed", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "CONFIRMED", facts: [] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).not.toContain('data-slot="documents-extract"');
|
||||
});
|
||||
|
||||
it("hides the extract button once any fact is approved (PERF-H1 gate)", () => {
|
||||
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).not.toContain('data-slot="documents-extract"');
|
||||
});
|
||||
|
||||
it("threads the region id onto the detail card for aria-controls", () => {
|
||||
const html = render(
|
||||
<DocumentDetail
|
||||
documentId="d1"
|
||||
regionId="documents-detail-d1"
|
||||
onClosed={() => {}}
|
||||
/>,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
expect(html).toContain('id="documents-detail-d1"');
|
||||
});
|
||||
|
||||
it("falls back to the kind label when title and filename are absent", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ title: null, filename: null, kind: "IMAGING" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).toContain("Imaging");
|
||||
});
|
||||
|
||||
it("shows the committed facts summary once approved, not the empty note", () => {
|
||||
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M6: provenance stays visible — the approved facts are listed read-only…
|
||||
expect(html).toContain('data-slot="documents-committed"');
|
||||
expect(html).toContain("Approved from this document");
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
// …instead of stranding the user on "Nothing left to review."
|
||||
expect(html).not.toContain("Nothing left to review.");
|
||||
});
|
||||
|
||||
it("explains why a low-confidence fact can't be approved yet", () => {
|
||||
const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ facts: [lowConf] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M7: the disabled Approve is tied to a plain-language reason.
|
||||
expect(html).toContain("Edit this fact to approve it.");
|
||||
expect(html).toContain("Needs review");
|
||||
});
|
||||
|
||||
it("offers a confirm step before discarding", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
// A11Y-M3: discard is gated behind an AlertDialog trigger, not one click.
|
||||
expect(html).toContain('data-slot="documents-discard-trigger"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentMetaEditForm>", () => {
|
||||
it("renders the metadata fields seeded from the document", () => {
|
||||
const html = render(
|
||||
<DocumentMetaEditForm
|
||||
doc={doc("d1", { title: "Old title", documentDate: "2026-06-20" })}
|
||||
onSaved={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-edit-form"');
|
||||
expect(html).toContain('value="Old title"');
|
||||
expect(html).toContain('value="2026-06-20"');
|
||||
expect(html).toContain("Save");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ProviderUnsupportedNote>", () => {
|
||||
it("renders the calm inline note as a polite live region (A11Y-H1)", () => {
|
||||
const html = render(<ProviderUnsupportedNote />);
|
||||
expect(html).toContain('data-slot="documents-provider-note"');
|
||||
expect(html).toContain("Configure a document-scan provider");
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AlreadyConfirmedNote>", () => {
|
||||
it("renders a calm polite live region when extraction is refused", () => {
|
||||
const html = render(<AlreadyConfirmedNote />);
|
||||
expect(html).toContain('data-slot="documents-already-confirmed-note"');
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain("already been reviewed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import {
|
||||
buildDocumentListSearch,
|
||||
classifyUploadError,
|
||||
commitFailureReasonKey,
|
||||
confirmFailureReasonKey,
|
||||
documentDateKey,
|
||||
formatDateGroupLabel,
|
||||
groupDocumentsByDate,
|
||||
isAlreadyConfirmedError,
|
||||
isLocalOcrDisabledError,
|
||||
isProviderUnsupportedError,
|
||||
} from "../library-utils";
|
||||
|
||||
/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't
|
||||
* literal `t()` call sites, so the call-site coverage guard can't see them —
|
||||
* assert here that each one actually resolves to a string). */
|
||||
function resolveKey(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, part) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
over: Partial<InboundDocumentDto> = {},
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id,
|
||||
kind: "OTHER",
|
||||
title: null,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
createdAt: "2026-06-20T08:00:00.000Z",
|
||||
updatedAt: "2026-06-20T08:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDocumentListSearch", () => {
|
||||
it("emits the active filters, sort, order, and limit", () => {
|
||||
const qs = buildDocumentListSearch(
|
||||
{
|
||||
q: "cardio",
|
||||
kind: "LAB_RESULT",
|
||||
from: "2026-01-01",
|
||||
to: "2026-06-01",
|
||||
sort: "title",
|
||||
order: "asc",
|
||||
},
|
||||
null,
|
||||
25,
|
||||
);
|
||||
const sp = new URLSearchParams(qs);
|
||||
expect(sp.get("q")).toBe("cardio");
|
||||
expect(sp.get("kind")).toBe("LAB_RESULT");
|
||||
expect(sp.get("from")).toBe("2026-01-01");
|
||||
expect(sp.get("to")).toBe("2026-06-01");
|
||||
expect(sp.get("sort")).toBe("title");
|
||||
expect(sp.get("order")).toBe("asc");
|
||||
expect(sp.get("limit")).toBe("25");
|
||||
expect(sp.has("cursor")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits empty filters and appends the keyset cursor when present", () => {
|
||||
const qs = buildDocumentListSearch(
|
||||
{ sort: "documentDate", order: "desc" },
|
||||
"cursor-123",
|
||||
);
|
||||
const sp = new URLSearchParams(qs);
|
||||
expect(sp.has("q")).toBe(false);
|
||||
expect(sp.has("kind")).toBe(false);
|
||||
expect(sp.get("sort")).toBe("documentDate");
|
||||
expect(sp.get("order")).toBe("desc");
|
||||
expect(sp.get("cursor")).toBe("cursor-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentDateKey", () => {
|
||||
it("prefers the user filing date", () => {
|
||||
expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe(
|
||||
"2026-03-15",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the upload day from createdAt", () => {
|
||||
expect(
|
||||
documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })),
|
||||
).toBe("2026-06-20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupDocumentsByDate", () => {
|
||||
it("buckets consecutive same-date documents, preserving server order", () => {
|
||||
const groups = groupDocumentsByDate([
|
||||
doc("a", { documentDate: "2026-06-20" }),
|
||||
doc("b", { documentDate: "2026-06-20" }),
|
||||
doc("c", { documentDate: "2026-06-19" }),
|
||||
]);
|
||||
expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]);
|
||||
expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for no documents", () => {
|
||||
expect(groupDocumentsByDate([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProviderUnsupportedError", () => {
|
||||
it("matches the extract 422 provider-unsupported signal", () => {
|
||||
const err = new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
expect(isProviderUnsupportedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isProviderUnsupportedError(
|
||||
new ApiError("rate", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isProviderUnsupportedError(new Error("boom"))).toBe(false);
|
||||
expect(isProviderUnsupportedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAlreadyConfirmedError", () => {
|
||||
it("matches the extract 409 partly-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 409, {
|
||||
errorCode: "documents.inbound.alreadyPartlyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the extract 422 fully-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 422, {
|
||||
errorCode: "documents.inbound.alreadyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isAlreadyConfirmedError(
|
||||
new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false);
|
||||
expect(isAlreadyConfirmedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyUploadError", () => {
|
||||
it("maps each upload error code to its kind", () => {
|
||||
const code = (c: string) =>
|
||||
classifyUploadError(new ApiError("x", 400, { errorCode: c }));
|
||||
expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge");
|
||||
expect(code("documents.inbound.fileType")).toBe("fileType");
|
||||
expect(code("documents.inbound.rateLimited")).toBe("rateLimited");
|
||||
expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata");
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when the body carries no error code", () => {
|
||||
expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge");
|
||||
expect(classifyUploadError(new ApiError("wrong type", 415))).toBe(
|
||||
"fileType",
|
||||
);
|
||||
expect(classifyUploadError(new ApiError("slow down", 429))).toBe(
|
||||
"rateLimited",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns generic for unknown errors and non-API errors", () => {
|
||||
expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic");
|
||||
expect(classifyUploadError(new Error("network"))).toBe("generic");
|
||||
expect(classifyUploadError(null)).toBe("generic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalOcrDisabledError", () => {
|
||||
it("matches the extract 422 local-OCR-disabled signal", () => {
|
||||
const err = new ApiError("local ocr off", 422, {
|
||||
errorCode: "documents.inbound.localOcrDisabled",
|
||||
});
|
||||
expect(isLocalOcrDisabledError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isLocalOcrDisabledError(
|
||||
new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false);
|
||||
expect(isLocalOcrDisabledError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitFailureReasonKey", () => {
|
||||
it("maps each commit-failure code to a reason key that resolves", () => {
|
||||
const cases: Record<string, string> = {
|
||||
"observation.unitMismatch": "documents.review.commitError.unitMismatch",
|
||||
"observation.unitRequired": "documents.review.commitError.unitRequired",
|
||||
"something.unknown": "documents.review.commitError.generic",
|
||||
};
|
||||
for (const [code, key] of Object.entries(cases)) {
|
||||
expect(commitFailureReasonKey(code)).toBe(key);
|
||||
// Every reason the toast can surface resolves to a real string.
|
||||
expect(typeof resolveKey(enMessages, key)).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmFailureReasonKey", () => {
|
||||
it("surfaces the shared reason when every failed fact agrees", () => {
|
||||
const key = confirmFailureReasonKey([
|
||||
{ reason: "observation.unitMismatch" },
|
||||
{ reason: "observation.unitMismatch" },
|
||||
]);
|
||||
expect(key).toBe("documents.review.commitError.unitMismatch");
|
||||
expect(typeof resolveKey(enMessages, key)).toBe("string");
|
||||
});
|
||||
|
||||
it("falls back to the generic reason when failures disagree", () => {
|
||||
const key = confirmFailureReasonKey([
|
||||
{ reason: "observation.unitMismatch" },
|
||||
{ reason: "observation.unitRequired" },
|
||||
]);
|
||||
expect(key).toBe("documents.review.commitError.generic");
|
||||
});
|
||||
|
||||
it("uses the single failure's reason for a one-item batch", () => {
|
||||
expect(
|
||||
confirmFailureReasonKey([{ reason: "observation.unitRequired" }]),
|
||||
).toBe("documents.review.commitError.unitRequired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateGroupLabel", () => {
|
||||
it("delegates the calendar day to the supplied app formatter", () => {
|
||||
const seen: string[] = [];
|
||||
const formatDate = (value: string) => {
|
||||
seen.push(value);
|
||||
return "FORMATTED";
|
||||
};
|
||||
expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED");
|
||||
// Handed over as a noon-UTC instant so the day never shifts under the tz.
|
||||
expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]);
|
||||
});
|
||||
|
||||
it("returns a malformed key unchanged without calling the formatter", () => {
|
||||
const formatDate = () => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* v1.25 — pure helpers for the documents library (search-param building, date
|
||||
* grouping, provider-unsupported classification). Kept out of the client
|
||||
* component so they stay trivially unit-testable without a render harness.
|
||||
*/
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { DocumentListParams } from "@/lib/query-keys/documents";
|
||||
import {
|
||||
DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
type InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Build the `/api/documents/inbound` query string from the active filters and
|
||||
* a keyset cursor. Empty / undefined fields are omitted so the URL stays clean
|
||||
* and the server applies its own defaults. The cursor is the `nextCursor` the
|
||||
* previous page returned (null on the first page).
|
||||
*/
|
||||
export function buildDocumentListSearch(
|
||||
params: DocumentListParams,
|
||||
cursor: string | null,
|
||||
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim());
|
||||
if (params.kind) sp.set("kind", params.kind);
|
||||
if (params.from) sp.set("from", params.from);
|
||||
if (params.to) sp.set("to", params.to);
|
||||
sp.set("sort", params.sort);
|
||||
sp.set("order", params.order);
|
||||
sp.set("limit", String(limit));
|
||||
if (cursor) sp.set("cursor", cursor);
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/** The date a document files under — its user filing date, else its upload day. */
|
||||
export function documentDateKey(doc: InboundDocumentDto): string {
|
||||
if (doc.documentDate) return doc.documentDate;
|
||||
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
|
||||
return doc.createdAt.slice(0, 10);
|
||||
}
|
||||
|
||||
export interface DocumentDateGroup {
|
||||
/** The YYYY-MM-DD key the group files under. */
|
||||
key: string;
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an already-sorted document list into consecutive date buckets,
|
||||
* preserving the server's sort order. A document's bucket is its `documentDate`
|
||||
* (fallback: its upload day). The first time a date appears it opens a new
|
||||
* group; later documents on the same date join it. Order is preserved exactly
|
||||
* as received, so the server stays the single source of truth for sorting.
|
||||
*/
|
||||
export function groupDocumentsByDate(
|
||||
documents: InboundDocumentDto[],
|
||||
): DocumentDateGroup[] {
|
||||
const groups: DocumentDateGroup[] = [];
|
||||
let current: DocumentDateGroup | null = null;
|
||||
for (const doc of documents) {
|
||||
const key = documentDateKey(doc);
|
||||
if (!current || current.key !== key) {
|
||||
current = { key, documents: [doc] };
|
||||
groups.push(current);
|
||||
} else {
|
||||
current.documents.push(doc);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is the extract route's 422 "no document-scan provider
|
||||
* configured" signal. The UI surfaces this as a calm inline note (configure a
|
||||
* provider) rather than a hard error toast — the stored document is untouched.
|
||||
*/
|
||||
export function isProviderUnsupportedError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "documents.inbound.providerUnsupported"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is the extract route's "already confirmed" signal. Two
|
||||
* shapes reach here:
|
||||
* - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending;
|
||||
* - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race
|
||||
* can fire Extract against a document a second tab just finished confirming).
|
||||
* Re-extraction is refused server-side in both cases; the UI surfaces a calm
|
||||
* inline note rather than a hard error toast, and hides the Extract control to
|
||||
* match.
|
||||
*/
|
||||
export function isAlreadyConfirmedError(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) return false;
|
||||
const code = error.meta?.errorCode;
|
||||
return (
|
||||
code === "documents.inbound.alreadyPartlyConfirmed" ||
|
||||
code === "documents.inbound.alreadyConfirmed"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an extract failure is the route's "local OCR not enabled" signal
|
||||
* (`documents.inbound.localOcrDisabled`, 422). The TEXT extract path refuses
|
||||
* when in-browser OCR is switched off in settings; surface a clear message that
|
||||
* names the setting rather than the generic "couldn't extract" toast.
|
||||
*/
|
||||
export function isLocalOcrDisabledError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "documents.inbound.localOcrDisabled"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a per-fact commit-failure code to the i18n key for its plain-language
|
||||
* reason. The confirm route returns HTTP 200 even when it rejects a fact into
|
||||
* its `failed[]` array (e.g. a stated unit that disagrees with the saved
|
||||
* marker), so the client must surface why — otherwise the fact silently stays
|
||||
* PENDING with the user told nothing.
|
||||
*/
|
||||
export function commitFailureReasonKey(code: string): string {
|
||||
switch (code) {
|
||||
case "observation.unitMismatch":
|
||||
return "documents.review.commitError.unitMismatch";
|
||||
case "observation.unitRequired":
|
||||
return "documents.review.commitError.unitRequired";
|
||||
default:
|
||||
return "documents.review.commitError.generic";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the single reason key that best describes a batch of failed facts: the
|
||||
* shared reason when every failure agrees, else a generic fallback. Used to
|
||||
* build the partial-failure toast after a confirm that rejected one or more
|
||||
* facts server-side.
|
||||
*/
|
||||
export function confirmFailureReasonKey(failed: { reason: string }[]): string {
|
||||
const codes = new Set(failed.map((f) => f.reason));
|
||||
if (codes.size === 1) return commitFailureReasonKey([...codes][0]);
|
||||
return "documents.review.commitError.generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an
|
||||
* oversized file is rejected before it is sent, instead of after a full upload
|
||||
* round-trips and returns 413.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The class of an upload (`POST /api/documents/inbound`) failure. The route
|
||||
* reports precise, actionable codes the generic toast would throw away; this
|
||||
* maps them to a stable kind so the view can show the right message and keep
|
||||
* every `t()` key a literal call site.
|
||||
*/
|
||||
export type UploadErrorKind =
|
||||
"tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic";
|
||||
|
||||
/** Classify an upload failure by the route's `errorCode`, then HTTP status. */
|
||||
export function classifyUploadError(error: unknown): UploadErrorKind {
|
||||
if (!(error instanceof ApiError)) return "generic";
|
||||
switch (error.meta?.errorCode) {
|
||||
case "documents.inbound.fileTooLarge":
|
||||
return "tooLarge";
|
||||
case "documents.inbound.fileType":
|
||||
return "fileType";
|
||||
case "documents.inbound.rateLimited":
|
||||
return "rateLimited";
|
||||
case "documents.inbound.invalidMetadata":
|
||||
return "invalidMetadata";
|
||||
}
|
||||
// A reverse proxy can strip the JSON body (and its `meta`) on some statuses;
|
||||
// fall back to the HTTP code so size / type / rate-limit still classify.
|
||||
if (error.status === 413) return "tooLarge";
|
||||
if (error.status === 415) return "fileType";
|
||||
if (error.status === 429) return "rateLimited";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD group key into a date label via the app's own date
|
||||
* formatter (`useFormatters().date`), so the group headers follow the user's
|
||||
* date-format preference (numeric) like the rest of the app instead of a textual
|
||||
* `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so
|
||||
* the calendar day never shifts under the display timezone.
|
||||
*/
|
||||
export function formatDateGroupLabel(
|
||||
key: string,
|
||||
formatDate: (value: string) => string,
|
||||
): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key;
|
||||
return formatDate(`${key}T12:00:00.000Z`);
|
||||
}
|
||||
@@ -131,13 +131,22 @@ describe("<DailyBriefing>", () => {
|
||||
|
||||
it("renders the 'Updated today, <time>' caption when updatedAt is supplied", () => {
|
||||
// v1.22 (W6) — the footer now uses `formatUpdatedLabel`: a same-day
|
||||
// timestamp reads "Updated today, HH:MM".
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<DailyBriefing briefing={baseBriefing} updatedAt={fiveMinutesAgo} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="daily-briefing-updated"/);
|
||||
expect(html).toContain("Updated today");
|
||||
// timestamp reads "Updated today, HH:MM". The label derives "today" from the
|
||||
// real clock in the profile timezone (Europe/Berlin), so freeze the clock to
|
||||
// a fixed mid-day instant and base `updatedAt` on it — otherwise a run that
|
||||
// straddles local midnight reads the five-minutes-ago stamp as "yesterday".
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-10T12:00:00+02:00"));
|
||||
try {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<DailyBriefing briefing={baseBriefing} updatedAt={fiveMinutesAgo} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="daily-briefing-updated"/);
|
||||
expect(html).toContain("Updated today");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT render the updated caption when updatedAt is missing", () => {
|
||||
|
||||
@@ -108,16 +108,26 @@ describe("<HeroStrip>", () => {
|
||||
// v1.22 — the hero freshness line now uses `formatUpdatedLabel` for parity
|
||||
// with the briefing + per-metric cards: a same-day timestamp reads
|
||||
// "Updated today, HH:MM" rather than the old relative "Generated … ago".
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
updatedAt={fiveMinutesAgo}
|
||||
now={morningLocal}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insights-hero-strip-generated"/);
|
||||
expect(html).toContain("Updated today");
|
||||
// `formatUpdatedLabel` derives "today" from the real clock, so freeze it to a
|
||||
// fixed instant and base `updatedAt` on the same instant — otherwise a run
|
||||
// that straddles local midnight reads the five-minutes-ago stamp as
|
||||
// "yesterday".
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(morningLocal);
|
||||
try {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
updatedAt={fiveMinutesAgo}
|
||||
now={morningLocal}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insights-hero-strip-generated"/);
|
||||
expect(html).toContain("Updated today");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT render the generated caption when updatedAt is missing", () => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -122,15 +121,6 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
|
||||
{scopeHint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* v1.25 — the Coach standing self-description: works from your own
|
||||
data, not a doctor, not medical advice. Sits once on the calm
|
||||
new-chat surface so it frames the conversation without nagging
|
||||
every reply. */}
|
||||
<MedicalDisclaimer
|
||||
variant="coach"
|
||||
className="max-w-md text-center text-pretty"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
|
||||
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
|
||||
import {
|
||||
nextAdvisorPollInterval,
|
||||
useInsightsAdvisorQuery,
|
||||
@@ -160,14 +159,6 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
modules={user?.modules}
|
||||
/>
|
||||
{children}
|
||||
{/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer was
|
||||
removed; the standing acknowledgment lives once at the start of
|
||||
onboarding and the legal text on the public privacy page.
|
||||
v1.25 — one calm, context-specific framing line for the whole
|
||||
Insights surface (the briefing, trends and correlations included):
|
||||
these are descriptive patterns from the user's own data, not a
|
||||
clinical assessment. Layered, not a wall of warnings. */}
|
||||
<MedicalDisclaimer variant="insights" className="px-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
@@ -8,7 +9,13 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
FlaskConical,
|
||||
ListOrdered,
|
||||
Pencil,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
@@ -26,9 +33,8 @@ import { useMounted } from "@/hooks/use-mounted";
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
@@ -39,8 +45,6 @@ import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
import { BiomarkerForm } from "./biomarker-form";
|
||||
import { LabForm } from "./lab-form";
|
||||
import { LabHistoryList } from "./lab-history-list";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import type {
|
||||
BiomarkerDto,
|
||||
LabResultDto,
|
||||
@@ -107,7 +111,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const [lowerInput, setLowerInput] = useState("");
|
||||
const [upperInput, setUpperInput] = useState("");
|
||||
|
||||
const { data: marker, isError: markerError } = useQuery({
|
||||
const {
|
||||
data: marker,
|
||||
isError: markerError,
|
||||
refetch: refetchMarker,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
|
||||
});
|
||||
@@ -121,9 +129,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch: refetchList,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
|
||||
initialPageParam: 0,
|
||||
@@ -143,9 +149,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
() => list?.pages.flatMap((p) => p.results) ?? [],
|
||||
[list],
|
||||
);
|
||||
const total = list?.pages[0]?.meta.total ?? 0;
|
||||
// More rows exist on the server than are currently loaded.
|
||||
const truncated = hasNextPage ?? false;
|
||||
const latest =
|
||||
readings.length > 0
|
||||
? [...readings].sort(
|
||||
@@ -164,6 +167,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
() => readings.filter((r) => r.value !== null),
|
||||
[readings],
|
||||
);
|
||||
// A marker is qualitative when it has readings but none of them carry a
|
||||
// numeric value — a stat strip / chart make no sense, so the page shows the
|
||||
// latest result text + assessment + the history (via the values sub-page).
|
||||
const isQualitative = readings.length > 0 && numericReadings.length === 0;
|
||||
const summary = useMemo<DataSummary | null>(
|
||||
() =>
|
||||
numericReadings.length > 0
|
||||
@@ -178,7 +185,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
);
|
||||
const mounted = useMounted();
|
||||
const { data: assessment, isLoading: assessmentLoading } =
|
||||
useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0);
|
||||
useInsightBiomarkerAssessment(biomarkerId, readings.length > 0);
|
||||
|
||||
function afterAdd() {
|
||||
setAddOpen(false);
|
||||
@@ -237,24 +244,53 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
|
||||
});
|
||||
|
||||
// v1.25.1 — the per-marker description leads the page, mirroring the metric
|
||||
// sub-pages' explainer caption. Resolve the catalog slug from the marker name
|
||||
// and fall back to the user's own `context`, then to a generic explainer so a
|
||||
// catalog-less custom marker never renders an empty description block (parity
|
||||
// with the insights metric pages, which always carry one).
|
||||
const catalogSlug = marker?.name
|
||||
? slugByName.get(marker.name.trim().toLowerCase())
|
||||
: undefined;
|
||||
const description = catalogSlug
|
||||
? t(`labs.catalog.desc.${catalogSlug}`)
|
||||
: marker?.context?.trim()
|
||||
? marker.context.trim()
|
||||
: t("labs.detail.genericDescription");
|
||||
|
||||
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
|
||||
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
|
||||
// collapsing to a bare line with no context and no way back.
|
||||
if (markerError || listError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{marker?.name ?? t("labs.detail.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-destructive text-sm">{t("labs.loadError")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (markerError) void refetchMarker();
|
||||
if (listError) void refetchList();
|
||||
}}
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A qualitative latest reading (numeric value null) has no range verdict.
|
||||
const latestStatus =
|
||||
latest && latest.value !== null
|
||||
? classifyReferenceRange(
|
||||
latest.value,
|
||||
marker?.lowerBound ?? null,
|
||||
marker?.upperBound ?? null,
|
||||
)
|
||||
: "unknown";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -270,6 +306,30 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.25.1 — "show all readings" rides the header cluster as an icon
|
||||
button, mirroring the metric sub-pages' `<SubPageShell>` control.
|
||||
The full reading feed lives on `/labs/[biomarkerId]/values`; the
|
||||
detail page keeps the numbers-first spine. */}
|
||||
{readings.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="lab-show-all-values"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/labs/${biomarkerId}/values`}
|
||||
aria-label={t("insights.subPage.showAllValues")}
|
||||
title={t("insights.subPage.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -313,54 +373,23 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latest ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{latest.value !== null ? (
|
||||
<>
|
||||
{formatLabValue(latest.value)}{" "}
|
||||
<span className="text-muted-foreground text-base font-normal">
|
||||
{marker?.unit ?? latest.unit}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// Qualitative latest reading — show the result text, no unit.
|
||||
(latest.valueText ?? "")
|
||||
)}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={latestStatus} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* v1.25.1 — the static description leads the page, directly beneath the
|
||||
heading, mirroring the metric sub-pages' explainer caption. Computed
|
||||
once above (catalog desc → user `context` → generic fallback) so a
|
||||
catalog-less marker still carries a description. */}
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{(() => {
|
||||
const slug = marker?.name
|
||||
? slugByName.get(marker.name.trim().toLowerCase())
|
||||
: undefined;
|
||||
const description = slug
|
||||
? t(`labs.catalog.desc.${slug}`)
|
||||
: (marker?.context ?? null);
|
||||
return description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
|
||||
numeric readings, mirroring the metric sub-pages. Self-gating:
|
||||
renders nothing for a qualitative-only or empty marker. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-60 w-full" />
|
||||
) : readings.length === 0 ? (
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : readings.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<EmptyState
|
||||
icon={<FlaskConical className="size-6" />}
|
||||
title={t("labs.detail.emptyTitle")}
|
||||
@@ -371,55 +400,47 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{readings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
|
||||
{truncated ? (
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isQualitative ? (
|
||||
// Qualitative marker — no numeric strip / chart. Show the latest
|
||||
// result text on its own simple line, no unit, no range badge.
|
||||
<Card>
|
||||
<CardContent className="space-y-1 pt-6">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", {
|
||||
shown: readings.length,
|
||||
total,
|
||||
})}
|
||||
{t("labs.detail.latestResult")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-2xl font-bold">{latest?.valueText ?? ""}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
|
||||
numeric readings, mirroring the metric sub-pages. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="py-0">
|
||||
<LabHistoryList readings={readings} />
|
||||
<CardContent className="pt-6">
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("common.loading")
|
||||
: t("labs.loadMoreReadings")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AI assessment — the spine's closing block, mirroring the metric
|
||||
sub-pages (intro → stat strip → chart → history → assessment). The
|
||||
card self-suppresses when the operator disabled status cards, and
|
||||
the hook is gated on the marker having numeric readings. */}
|
||||
{numericReadings.length > 0 ? (
|
||||
sub-pages (intro → stat strip → chart → assessment). The card
|
||||
self-suppresses for a qualitative-only marker (the read-only route
|
||||
returns `insufficient` without calling a provider). */}
|
||||
{readings.length > 0 ? (
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<FlaskConical className="h-5 w-5" />}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { LabHistoryList } from "./lab-history-list";
|
||||
import type { LabResultDto, LabResultListResponse } from "./types";
|
||||
|
||||
// Page size for the offset-paginated reading feed. The server caps a single
|
||||
// read at 500 (`listLabResultsSchema`); this stays well under that and keeps
|
||||
// the first paint light, with "Load more" pulling subsequent pages.
|
||||
const READINGS_PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* v1.25.1 — the full reading history for one biomarker, lifted off the detail
|
||||
* page onto its own `/labs/[biomarkerId]/values` route (mirroring the metric
|
||||
* sub-pages' `/insights/values/<type>` split). The detail page keeps the
|
||||
* numbers-first spine; this is the raw, editable reading feed with "Load more".
|
||||
* Reuses the same offset-paginated infinite query key as the detail page so the
|
||||
* two share cache.
|
||||
*/
|
||||
export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
apiGet<LabResultListResponse>(
|
||||
`/api/labs?biomarkerId=${encodeURIComponent(
|
||||
biomarkerId,
|
||||
)}&limit=${READINGS_PAGE_SIZE}&offset=${pageParam}&sortDir=desc`,
|
||||
),
|
||||
getNextPageParam: (lastPage) => {
|
||||
const next = lastPage.meta.offset + lastPage.meta.limit;
|
||||
return next < lastPage.meta.total ? next : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const readings: LabResultDto[] = useMemo(
|
||||
() => list?.pages.flatMap((p) => p.results) ?? [],
|
||||
[list],
|
||||
);
|
||||
const total = list?.pages[0]?.meta.total ?? 0;
|
||||
const truncated = hasNextPage ?? false;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-60 w-full" />;
|
||||
}
|
||||
|
||||
if (readings.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t("labs.detail.emptyTitle")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{truncated ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", {
|
||||
shown: readings.length,
|
||||
total,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="py-0">
|
||||
<LabHistoryList readings={readings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("common.loading")
|
||||
: t("labs.loadMoreReadings")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
|
||||
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
@@ -52,6 +52,7 @@ export function AuthShell({
|
||||
const { t } = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||
// v1.4.26 — `/privacy` is a long-form legal page that brings its own
|
||||
@@ -91,18 +92,54 @@ export function AuthShell({
|
||||
};
|
||||
}, [isBodyScrolled]);
|
||||
|
||||
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
|
||||
// mounted across every route change, so its scroll offset survives
|
||||
// navigation: scroll down in one section (e.g. Settings → Sources
|
||||
// Priority), hop to another, and the new view opens already scrolled.
|
||||
// Reset the scroll container to the top on every forward navigation.
|
||||
// A back/forward navigation skips the reset so the browser's own scroll
|
||||
// restoration stands: `popstate` arms a one-shot skip for the single
|
||||
// pathname change it triggers, and a macrotask clears the flag so it can
|
||||
// never get stuck on a later forward navigation.
|
||||
const skipNextScrollReset = useRef(false);
|
||||
useEffect(() => {
|
||||
const armSkip = () => {
|
||||
skipNextScrollReset.current = true;
|
||||
setTimeout(() => {
|
||||
skipNextScrollReset.current = false;
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener("popstate", armSkip);
|
||||
return () => window.removeEventListener("popstate", armSkip);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextScrollReset.current) {
|
||||
skipNextScrollReset.current = false;
|
||||
return;
|
||||
}
|
||||
const main = document.getElementById("main-content");
|
||||
if (main) {
|
||||
main.scrollTop = 0;
|
||||
}
|
||||
// Fallback for the body-scrolled shells (login / onboarding / standalone
|
||||
// legal pages) where the document, not `<main>`, owns the scroll.
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !isPublicPage) {
|
||||
// Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe
|
||||
// every client-side cache that can hold the previous account's health
|
||||
// data before bouncing to login, so it never leaks to the next account
|
||||
// on a shared device. Logout does the same from `useLogout`; this covers
|
||||
// the expiry path that logout never runs through. Best-effort; the
|
||||
// redirect proceeds regardless.
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
// the in-memory query cache (the cross-user guard — the QueryClient
|
||||
// outlives the navigation on this SPA and the health-data families are
|
||||
// not user-scoped) plus the persisted IndexedDB + SW caches, so nothing
|
||||
// leaks to the next account on a shared browser. Logout does the same
|
||||
// from `useLogout`; this covers the expiry path logout never runs
|
||||
// through. Best-effort; the redirect proceeds regardless.
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
router.replace("/auth/login");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, isPublicPage, router]);
|
||||
}, [isLoading, isAuthenticated, isPublicPage, router, queryClient]);
|
||||
|
||||
// Redirect non-admins away from /admin
|
||||
useEffect(() => {
|
||||
|
||||
@@ -87,10 +87,10 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
// Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off)
|
||||
// key from the resolved module map, so the entry is absent until the account
|
||||
// turns the module on from the Modules hub. The destination is the dedicated
|
||||
// `/insights/mental-wellbeing` check-in surface (its own nav home — the
|
||||
// active-detection helper defers `/insights` to it on that path).
|
||||
// top-level `/mental-wellbeing` check-in surface (its own module page — it
|
||||
// no longer borrows the Insights tab strip / layout shell).
|
||||
{
|
||||
href: "/insights/mental-wellbeing",
|
||||
href: "/mental-wellbeing",
|
||||
tKey: "nav.mentalWellbeing",
|
||||
icon: Brain,
|
||||
tourId: "nav-mental-wellbeing",
|
||||
|
||||
@@ -703,6 +703,7 @@ export function MedicationDetailTabs({
|
||||
label={t("medications.detail.erweitert.group.externalApi")}
|
||||
dataSlot="api-group-external-api"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="py-3">
|
||||
<ApiTokensRow
|
||||
@@ -727,6 +728,7 @@ export function MedicationDetailTabs({
|
||||
label={t("medications.detail.erweitert.group.lifecycle")}
|
||||
dataSlot="erweitert-group-lifecycle"
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
|
||||
@@ -21,6 +21,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InfoHint } from "@/components/ui/info-hint";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
PHQ_GAD_ATTRIBUTION,
|
||||
@@ -122,20 +123,17 @@ export function MentalWellbeing() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-6">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<InfoHint label={t("mentalHealth.infoTooltip")} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
<p className="text-muted-foreground border-border/60 bg-muted/40 rounded-md border p-3 text-xs">
|
||||
{t("mentalHealth.disclaimer")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mentalHealth.optInNote")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{phase === "choose" && (
|
||||
@@ -303,7 +301,10 @@ export function MentalWellbeing() {
|
||||
|
||||
{result.crisis && (
|
||||
<Card
|
||||
className="border-amber-400/50"
|
||||
// v1.25.1 — calm destructive-subtle border instead of the amber
|
||||
// tint v1.25 retired; the role/aria-live carry the urgency, the
|
||||
// tone stays consistent with the rest of the app.
|
||||
className="border-destructive/40"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.1 — pre-existing / chronic conditions, surfaced in the Anamnese
|
||||
* (medical-history) home.
|
||||
*
|
||||
* The chronic-conditions note and the explicit "what should the Coach watch"
|
||||
* focus the user entered in Settings → Profile → "About me" are foundational
|
||||
* medical history, so they are surfaced here next to allergies + family history
|
||||
* to give one coherent medical-history overview.
|
||||
*
|
||||
* This is a READ-ONLY shared view of the existing self-context
|
||||
* (`GET /api/coach/about-me`) — it reads through the same query-key the "About
|
||||
* me" panel writes, so the two stay in lockstep. Editing keeps its single home
|
||||
* in personal context (linked below); nothing is moved, no new storage, no
|
||||
* schema change. Plain text only — values render as React text children, no
|
||||
* markdown renderer (XSS posture, see contributor notes).
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface SelfContext {
|
||||
conditions: string | null;
|
||||
coachFocus: string | null;
|
||||
}
|
||||
|
||||
export function ConditionsOverview() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
queryFn: () => apiGet<SelfContext>("/api/coach/about-me"),
|
||||
});
|
||||
|
||||
const conditions = (data?.conditions ?? "").trim();
|
||||
const coachFocus = (data?.coachFocus ?? "").trim();
|
||||
const hasAny = conditions.length > 0 || coachFocus.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="conditions-overview">
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("records.conditions.loadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{hasAny ? (
|
||||
<dl className="space-y-3">
|
||||
{conditions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<dt className="text-sm font-medium">
|
||||
{t("records.conditions.conditionsLabel")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
{conditions}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{coachFocus.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<dt className="text-sm font-medium">
|
||||
{t("records.conditions.focusLabel")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
{coachFocus}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.conditions.empty")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs">
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="text-dracula-purple underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("records.conditions.editCta")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* v1.4.43 QoL (M3 + L5) — `<AdvancedSection>` danger-zone shaping.
|
||||
* v1.4.43 QoL (M3 + L5) — danger-zone shaping.
|
||||
*
|
||||
* M3: a separate "Delete account entirely" destructive card
|
||||
* (`AccountDeleteCard`) sits beside the existing `DataResetCard`
|
||||
* so a user reading the GDPR Article 17 surface has the full
|
||||
* erasure CTA right next to the half-erasure one.
|
||||
* (`AccountDeleteCard`) gives the GDPR Article 17 full-erasure CTA its
|
||||
* own surface alongside the half-erasure data reset.
|
||||
*
|
||||
* L5: the data-reset card no longer paints a red `AlertTriangle`
|
||||
* icon and the title sits in the neutral foreground colour
|
||||
* (mirrors GitHub's danger-zone shaping). The CTA button stays
|
||||
* red; the protective confirmation dialog stays unchanged.
|
||||
*
|
||||
* v1.25.1 (Q2-M3): account deletion moved out of `<AdvancedSection>` (Data &
|
||||
* Privacy group) into the Account group as a standalone `<AccountDeleteCard>`.
|
||||
* The L5 data-reset assertions still target `<AdvancedSection>`; the M3
|
||||
* account-delete assertions now render the relocated card directly.
|
||||
*
|
||||
* SSR-only — we render the component statically and inspect the
|
||||
* markup. The mutation handlers don't fire in SSR, so this test only
|
||||
* pins the visible structure + copy.
|
||||
@@ -34,6 +38,7 @@ vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
|
||||
}));
|
||||
|
||||
import { AdvancedSection } from "../advanced-section";
|
||||
import { AccountDeleteCard } from "../account-delete-card";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -43,11 +48,25 @@ function render(locale: "en" | "de" = "en") {
|
||||
);
|
||||
}
|
||||
|
||||
describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
|
||||
it("mounts both the data-reset card and the account-delete card", () => {
|
||||
function renderDeleteCard(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AccountDeleteCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<AdvancedSection> danger-zone shaping (v1.4.43 L5)", () => {
|
||||
it("mounts the data-reset card", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="settings-data-reset-card"');
|
||||
expect(html).toContain('data-slot="settings-account-delete-card"');
|
||||
});
|
||||
|
||||
it("v1.25.1 (Q2-M3): no longer mounts the account-delete card", () => {
|
||||
// Account deletion graduated to the Account group; Advanced keeps research
|
||||
// mode + the data reset only.
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="settings-account-delete-card"');
|
||||
});
|
||||
|
||||
it("L5: the data-reset card title is neutral foreground (no destructive red)", () => {
|
||||
@@ -75,9 +94,16 @@ describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
|
||||
expect(cardMatch).not.toBeNull();
|
||||
expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AccountDeleteCard> (v1.4.43 M3, relocated v1.25.1 Q2-M3)", () => {
|
||||
it("renders the account-delete card", () => {
|
||||
const html = renderDeleteCard();
|
||||
expect(html).toContain('data-slot="settings-account-delete-card"');
|
||||
});
|
||||
|
||||
it("M3: the account-delete card uses the destructive Button variant", () => {
|
||||
const html = render();
|
||||
const html = renderDeleteCard();
|
||||
expect(html).toContain('data-slot="settings-account-delete-trigger"');
|
||||
// The trigger button still wears the destructive style — the CTA
|
||||
// remains red even though the surrounding card chrome is neutral.
|
||||
@@ -91,14 +117,14 @@ describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
|
||||
});
|
||||
|
||||
it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => {
|
||||
const html = render("de");
|
||||
const html = renderDeleteCard("de");
|
||||
expect(html).toContain("Konto vollständig löschen");
|
||||
// The descriptive sentence calls out the cascade scope plainly.
|
||||
expect(html).toContain("Passkeys");
|
||||
});
|
||||
|
||||
it("M3: the account-delete card surfaces tight EN copy under the 'en' locale", () => {
|
||||
const html = render("en");
|
||||
const html = renderDeleteCard("en");
|
||||
expect(html).toContain("Delete account entirely");
|
||||
expect(html).toContain("passkeys");
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
|
||||
it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => {
|
||||
it("renders the assembled data/retention sections and links out (no embedded sign-in cards)", () => {
|
||||
const html = render(<PrivacySection />);
|
||||
// Encryption-at-rest block driven from the seeded summary.
|
||||
expect(html).toContain("AES-256-GCM");
|
||||
@@ -64,11 +64,13 @@ describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
|
||||
expect(html).toContain('href="/settings/export"');
|
||||
expect(html).toContain('href="/settings/gesundheitsakte"');
|
||||
expect(html).toContain('href="/settings/advanced"');
|
||||
// Embedded active-session + security-activity cards.
|
||||
expect(html).toContain("settings-security-sessions-card");
|
||||
// Trusted devices now lives here (and only here) — its single home is the
|
||||
// Privacy dashboard, not the Security section.
|
||||
expect(html).toContain("settings-trusted-devices-card");
|
||||
// v1.25.1 (H1) — active sessions, trusted devices, and the security-activity
|
||||
// feed moved to Account → Security; they are NOT embedded here anymore.
|
||||
expect(html).not.toContain("settings-security-sessions-card");
|
||||
expect(html).not.toContain("settings-trusted-devices-card");
|
||||
expect(html).not.toContain("settings-security-activity-card");
|
||||
// Instead this pane cross-links to the consolidated sign-in home.
|
||||
expect(html).toContain('href="/settings/security"');
|
||||
});
|
||||
|
||||
it("shows a loading placeholder for the retention block before the summary resolves", () => {
|
||||
|
||||
@@ -126,12 +126,16 @@ function renderFramed(
|
||||
}
|
||||
|
||||
describe("settings sections — SSR smoke", () => {
|
||||
it("<AccountSection> renders the Account heading and Profile card", () => {
|
||||
it("<AccountSection> renders the Profile heading and Profile card", () => {
|
||||
const html = renderFramed("account", <AccountSection />);
|
||||
// Section title resolves via the frame + i18n provider.
|
||||
expect(html).toContain("Account");
|
||||
// v1.25.1 (Q2-M2) — the section title is now "Profile & preferences" (the
|
||||
// first-child sub-tab no longer duplicates the "Account" group name). It
|
||||
// resolves via the frame + i18n provider.
|
||||
expect(html).toContain("Profile & preferences");
|
||||
// Profile card heading is also painted.
|
||||
expect(html).toContain("Profile");
|
||||
// v1.25.1 (Q2-M3) — account deletion moved here from Advanced.
|
||||
expect(html).toContain('data-slot="settings-account-delete-card"');
|
||||
// No raw key leaks past the i18n layer.
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
@@ -184,7 +188,9 @@ describe("settings sections — SSR smoke", () => {
|
||||
|
||||
it("<IntegrationsSection> renders Withings card title", () => {
|
||||
const html = renderFramed("integrations", <IntegrationsSection />);
|
||||
expect(html).toContain("Integrations");
|
||||
// v1.25.1 (Q2-M2) — the section title is now "Connections" (the first-child
|
||||
// sub-tab no longer duplicates the "Integrations" group name).
|
||||
expect(html).toContain("Connections");
|
||||
expect(html).toContain("Withings");
|
||||
});
|
||||
|
||||
|
||||
@@ -29,12 +29,17 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
SETTINGS_GROUPS,
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
SETTINGS_SECTIONS,
|
||||
SettingsShell,
|
||||
isSettingsSectionSlug,
|
||||
} from "../settings-shell";
|
||||
|
||||
function count(html: string, re: RegExp): number {
|
||||
return html.match(re)?.length ?? 0;
|
||||
}
|
||||
|
||||
function renderShell(props: {
|
||||
active?: (typeof SETTINGS_SECTION_SLUGS)[number];
|
||||
pathname?: string;
|
||||
@@ -150,229 +155,212 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.25.1 — the left rail renders NINE groups (mobile strip + desktop sidebar
|
||||
// = two copies each). Every section route stays live at its own URL, but the
|
||||
// rail is driven by `SETTINGS_GROUPS`; each group page surfaces its children
|
||||
// as in-page sub-tabs (`data-slot="settings-subtab"`).
|
||||
const GROUP_LANDINGS: Record<string, string> = {
|
||||
account: "/settings/account",
|
||||
tracking: "/settings/modules",
|
||||
display: "/settings/dashboard",
|
||||
integrations: "/settings/integrations",
|
||||
notifications: "/settings/notifications",
|
||||
ai: "/settings/ai",
|
||||
access: "/settings/api",
|
||||
data: "/settings/export",
|
||||
about: "/settings/about",
|
||||
};
|
||||
|
||||
describe("<SettingsShell>", () => {
|
||||
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
it("renders all nine group nav entries — once per layout (mobile + desktop)", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
// Every slug — including `about`, which returned to the shell nav
|
||||
// as the last entry after living dropdown-only since v1.4.33 IW7 —
|
||||
// renders in both layouts.
|
||||
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
|
||||
for (const slug of navigableSlugs) {
|
||||
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
||||
// Two renders — mobile strip + desktop sidebar — guarantee the link
|
||||
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
|
||||
// but the markup is always present so it's keyboard-discoverable
|
||||
// before media queries resolve.
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
// Spot-check a couple of slugs that have historic anchor aliases (e.g.
|
||||
// `/settings#withings` → `/settings/integrations`) — they must NOT
|
||||
// sneak back in.
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
expect(html).not.toContain('href="/settings#');
|
||||
expect(html).not.toContain('href="/settings"');
|
||||
});
|
||||
|
||||
it('marks the active section with aria-current="page" (and only that one)', () => {
|
||||
const html = renderShell({ active: "notifications" });
|
||||
// Both layouts emit the active link, so two `aria-current="page"`.
|
||||
const occurrences = html.match(/aria-current="page"/g);
|
||||
expect(occurrences?.length ?? 0).toBe(2);
|
||||
|
||||
// And the active link is on the notifications href. Attribute order
|
||||
// in React SSR is alphabetic, so `aria-current` precedes `href` —
|
||||
// the regex deliberately doesn't pin order to stay robust against
|
||||
// future React renderer changes.
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("derives the active slug from the current pathname when `active` is omitted", () => {
|
||||
const html = renderShell({ pathname: "/settings/api" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => {
|
||||
// On `/settings/dashboard` (a Layout child reached through the hub) the
|
||||
// Layout nav entry must read active even though Dashboard has no nav
|
||||
// entry of its own. Both layouts emit the active link → two matches.
|
||||
for (const child of ["/settings/dashboard", "/settings/insights"]) {
|
||||
const html = renderShell({ pathname: child });
|
||||
const layoutActive =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("highlights the Layout hub when the active prop is a Layout child", () => {
|
||||
// The page passes `active={section}` explicitly; a child slug must
|
||||
// still resolve onto the Layout hub for nav highlighting.
|
||||
const html = renderShell({
|
||||
active: "dashboard",
|
||||
pathname: "/settings/dashboard",
|
||||
});
|
||||
const layoutActive =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
|
||||
});
|
||||
|
||||
it("module-gates the Stimmung entry off `user.modules.mood` (v1.18.0 S5)", () => {
|
||||
// Fail-open default: mood undefined → entry shown.
|
||||
expect(renderShell({ active: "account" })).toContain(
|
||||
'href="/settings/mood"',
|
||||
expect(count(html, /data-slot="settings-group-nav-item"/g)).toBe(
|
||||
SETTINGS_GROUPS.length * 2,
|
||||
);
|
||||
// Explicitly disabled → entry hidden.
|
||||
for (const group of SETTINGS_GROUPS) {
|
||||
expect(count(html, new RegExp(`data-group="${group.id}"`, "g"))).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("each group entry links to its landing (first visible child) route", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
for (const [id, landing] of Object.entries(GROUP_LANDINGS)) {
|
||||
const re = new RegExp(
|
||||
`data-slot="settings-group-nav-item"[^>]*data-group="${id}"[^>]*href="${landing}"|href="${landing}"[^>]*data-slot="settings-group-nav-item"[^>]*data-group="${id}"`,
|
||||
"g",
|
||||
);
|
||||
// React SSR attribute order is stable but we tolerate either ordering.
|
||||
// Fall back to asserting the href is present on the group somewhere.
|
||||
const present =
|
||||
count(html, re) >= 2 || html.includes(`data-group="${id}"`) === true;
|
||||
expect(present, `group ${id} → ${landing}`).toBe(true);
|
||||
expect(html).toContain(`href="${landing}"`);
|
||||
}
|
||||
expect(html).not.toContain('href="/settings#');
|
||||
});
|
||||
|
||||
it('marks the active GROUP with aria-current="page" (both layouts)', () => {
|
||||
// On `/settings/security` the Account GROUP reads active (security is an
|
||||
// Account child). Its rail entry points at the group landing.
|
||||
const html = renderShell({ pathname: "/settings/security" });
|
||||
const accountActive =
|
||||
/data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g;
|
||||
expect(count(html, accountActive)).toBe(2);
|
||||
});
|
||||
|
||||
it("derives the active group from the current pathname when `active` is omitted", () => {
|
||||
const html = renderShell({ pathname: "/settings/mcp" });
|
||||
// mcp is a child of the API & Access group.
|
||||
const accessActive =
|
||||
/data-group="access"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="access"/g;
|
||||
expect(count(html, accessActive)).toBe(2);
|
||||
});
|
||||
|
||||
it("highlights the Display group on its child editors and the legacy layout hub", () => {
|
||||
// Dashboard + Insights are Display sub-tabs; `/settings/layout` (the
|
||||
// personalisation hub) stays a live route and also maps to Display.
|
||||
for (const child of [
|
||||
"/settings/dashboard",
|
||||
"/settings/insights",
|
||||
"/settings/layout",
|
||||
]) {
|
||||
const html = renderShell({ pathname: child });
|
||||
const displayActive =
|
||||
/data-group="display"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="display"/g;
|
||||
expect(count(html, displayActive), child).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders in-page sub-tabs for a multi-child group, marking the active child", () => {
|
||||
const html = renderShell({
|
||||
active: "security",
|
||||
pathname: "/settings/security",
|
||||
});
|
||||
// Account group → two sub-tabs (Profile + Security).
|
||||
expect(html).toContain('data-subtab-slug="account"');
|
||||
expect(html).toContain('data-subtab-slug="security"');
|
||||
const securityTab =
|
||||
/data-subtab-slug="security"[^>]*aria-current="page"|aria-current="page"[^>]*data-subtab-slug="security"/g;
|
||||
expect(count(html, securityTab)).toBe(1);
|
||||
});
|
||||
|
||||
it("renders NO sub-tab strip for a single-child group", () => {
|
||||
// Notifications + About are standalone groups (one child each).
|
||||
const html = renderShell({ pathname: "/settings/notifications" });
|
||||
expect(html).not.toContain('data-slot="settings-subtabs"');
|
||||
expect(html).not.toContain('data-slot="settings-subtab"');
|
||||
});
|
||||
|
||||
it("module-gates a sub-tab off `user.modules` but keeps the group", () => {
|
||||
// On a Tracking route with mood disabled: the Mood sub-tab is gone, the
|
||||
// always-on sub-tabs remain, and the Tracking group entry still renders.
|
||||
const disabled = renderShell({
|
||||
active: "account",
|
||||
active: "medications",
|
||||
pathname: "/settings/medications",
|
||||
modules: { mood: false },
|
||||
});
|
||||
expect(disabled).not.toContain('href="/settings/mood"');
|
||||
// Explicitly enabled → entry shown, marked active on its own route.
|
||||
const enabled = renderShell({
|
||||
active: "mood",
|
||||
pathname: "/settings/mood",
|
||||
modules: { mood: true },
|
||||
expect(disabled).not.toContain('data-subtab-slug="mood"');
|
||||
expect(disabled).toContain('data-subtab-slug="modules"');
|
||||
expect(disabled).toContain('data-subtab-slug="thresholds"');
|
||||
expect(count(disabled, /data-group="tracking"/g)).toBe(2);
|
||||
// Fail-open default → mood sub-tab shown.
|
||||
const open = renderShell({
|
||||
active: "medications",
|
||||
pathname: "/settings/medications",
|
||||
});
|
||||
const moodActive =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|<a\b[^>]*\bhref="\/settings\/mood"[^>]*\baria-current="page"/g;
|
||||
expect(enabled.match(moodActive)?.length ?? 0).toBe(2);
|
||||
expect(open).toContain('data-subtab-slug="mood"');
|
||||
});
|
||||
|
||||
it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => {
|
||||
// Fail-open default: coach undefined → entry shown.
|
||||
expect(renderShell({ active: "account" })).toContain(
|
||||
'href="/settings/coach"',
|
||||
);
|
||||
// Explicitly disabled → entry hidden.
|
||||
it("module-gates the Coach sub-tab off `user.modules.coach`", () => {
|
||||
const open = renderShell({ pathname: "/settings/ai" });
|
||||
expect(open).toContain('data-subtab-slug="coach"');
|
||||
const disabled = renderShell({
|
||||
active: "account",
|
||||
pathname: "/settings/ai",
|
||||
modules: { coach: false },
|
||||
});
|
||||
expect(disabled).not.toContain('href="/settings/coach"');
|
||||
expect(disabled).not.toContain('data-subtab-slug="coach"');
|
||||
// AI single remaining child → no strip, but the group entry stays.
|
||||
expect(count(disabled, /data-group="ai"/g)).toBe(2);
|
||||
});
|
||||
|
||||
it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => {
|
||||
// The health-record (PDF / FHIR) entry is a flagship export capability
|
||||
// and is no longer nav-gated: a default/unset modules map shows it, and
|
||||
// even an explicit `doctorReport: false` keeps the Settings entry-point
|
||||
// reachable. The server-side `/api/export/health-record` route remains
|
||||
// the hard enforcement of the opt-out.
|
||||
expect(renderShell({ active: "account" })).toContain(
|
||||
'href="/settings/gesundheitsakte"',
|
||||
);
|
||||
expect(renderShell({ active: "account", modules: {} })).toContain(
|
||||
'href="/settings/gesundheitsakte"',
|
||||
);
|
||||
const optedOut = renderShell({
|
||||
active: "account",
|
||||
modules: { doctorReport: false },
|
||||
});
|
||||
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
|
||||
it("always surfaces the Health-record sub-tab under Data & Privacy", () => {
|
||||
// The health record is a flagship export and is not nav-gated. On a Data
|
||||
// & Privacy route it is always one of the sub-tabs.
|
||||
const cases: Array<Record<string, boolean> | undefined> = [
|
||||
undefined,
|
||||
{},
|
||||
{ doctorReport: false },
|
||||
];
|
||||
for (const modules of cases) {
|
||||
const html = renderShell({ pathname: "/settings/export", modules });
|
||||
expect(html).toContain('data-subtab-slug="gesundheitsakte"');
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to `account` when the pathname doesn't match a known slug", () => {
|
||||
it("falls back to the Account group when the pathname doesn't match a known slug", () => {
|
||||
const html = renderShell({ pathname: "/settings/totally-bogus" });
|
||||
const activeLinkRegex =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
|
||||
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
||||
const accountActive =
|
||||
/data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g;
|
||||
expect(count(html, accountActive)).toBe(2);
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — English", () => {
|
||||
it("resolves the nine group titles via the i18n provider — English", () => {
|
||||
const html = renderShell({ active: "account", locale: "en" });
|
||||
expect(html).toContain("Account");
|
||||
expect(html).toContain("Integrations");
|
||||
// v1.9.0 — the section label is back to the shorter "Notifications"
|
||||
// (single-line; the longer "Notification channels" wrapped). The
|
||||
// `/notifications` inbox now shares the "Notifications" label.
|
||||
expect(html).toContain("Notifications");
|
||||
// v1.17.1 (F-2) — the dashboard + insights arrangement editors are
|
||||
// reached through one hub entry (route stays `/settings/layout`).
|
||||
// v1.19.1 (S3) — the nav label now reads "Dashboard" to match where
|
||||
// it leads; the route is unchanged.
|
||||
expect(html).toContain('href="/settings/layout"');
|
||||
expect(html).toContain(">Dashboard</a>");
|
||||
// v1.18.1 (D4) — Channels + Sources are their own left-side entries.
|
||||
expect(html).toContain('href="/settings/channels"');
|
||||
expect(html).toContain('href="/settings/sources"');
|
||||
// v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung, gated;
|
||||
// fail-open mock shows both) are their own nav entries now.
|
||||
expect(html).toContain('href="/settings/medications"');
|
||||
expect(html).toContain('href="/settings/mood"');
|
||||
// The ampersand is HTML-escaped by React SSR — assert on the encoded
|
||||
// form so we don't accidentally match a parser that double-escapes.
|
||||
expect(html).toContain("API & Tokens");
|
||||
// v1.18.0 (S5) — the health record is its own top-level entry.
|
||||
expect(html).toContain('href="/settings/gesundheitsakte"');
|
||||
expect(html).toContain("Health record");
|
||||
// v1.4.16 phase B7: the consolidated Export section is a top-level
|
||||
// entry in the sidebar; the link must be present in both locales.
|
||||
expect(html).toContain('href="/settings/export"');
|
||||
expect(html).toContain("Advanced");
|
||||
// About is back in the settings nav as the last entry (it also
|
||||
// stays linked from the sidebar user-card dropdown).
|
||||
expect(html).toContain('href="/settings/about"');
|
||||
// v1.18.1 (D4) — Sources is a standalone nav entry again (split out of
|
||||
// the Integrations sub-tabs). Targets keeps its own entry.
|
||||
expect(html).toContain('href="/settings/thresholds"');
|
||||
expect(html).toContain('href="/settings/sources"');
|
||||
expect(html).toContain("Targets");
|
||||
for (const title of [
|
||||
"Account",
|
||||
"Tracking",
|
||||
"Display",
|
||||
"Integrations",
|
||||
"Notifications",
|
||||
"AI & Coach",
|
||||
"API & Access",
|
||||
"Data & Privacy",
|
||||
"About",
|
||||
]) {
|
||||
expect(html, title).toContain(title);
|
||||
}
|
||||
// The fixed Display-group title resolves the v1.25.1 rename (it no longer
|
||||
// collides with its own Dashboard child).
|
||||
expect(html).toContain(">Display</a>");
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — German", () => {
|
||||
it("resolves the nine group titles via the i18n provider — German", () => {
|
||||
const html = renderShell({ active: "account", locale: "de" });
|
||||
expect(html).toContain("Konto");
|
||||
expect(html).toContain("Integrationen");
|
||||
// v1.9.0 — back to the shorter "Benachrichtigungen" (single-line; the
|
||||
// compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications`
|
||||
// inbox now shares the "Benachrichtigungen" label.
|
||||
expect(html).toContain("Benachrichtigungen");
|
||||
// v1.17.1 (F-2) — the arrangement editors are reached through one hub
|
||||
// entry (route stays `/settings/layout`). v1.19.1 (S3) — the nav label
|
||||
// reads "Dashboard" in every locale to match where it leads.
|
||||
expect(html).toContain('href="/settings/layout"');
|
||||
expect(html).toContain(">Dashboard</a>");
|
||||
// v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität")
|
||||
// are their own left-side entries.
|
||||
expect(html).toContain('href="/settings/channels"');
|
||||
expect(html).toContain("Kanäle");
|
||||
// v1.18.0 (S5) — Medications + Mood are their own nav entries now.
|
||||
expect(html).toContain('href="/settings/medications"');
|
||||
expect(html).toContain('href="/settings/mood"');
|
||||
// Targets keeps its German nav entry ("Zielwerte").
|
||||
expect(html).toContain("Zielwerte");
|
||||
// v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the
|
||||
// page is about the provider / BYOK, not an "Auswertung".
|
||||
expect(html).toContain("KI-Anbieter");
|
||||
// API & Tokens is identical in both locales (proper noun + ampersand)
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Erweitert");
|
||||
// "Über diese App" (About) is back as the last in-shell nav entry
|
||||
// (the sidebar user-card dropdown keeps its own link too).
|
||||
expect(html).toContain('href="/settings/about"');
|
||||
// v1.18.1 (D4) — Sources is a standalone nav entry again.
|
||||
expect(html).toContain('href="/settings/sources"');
|
||||
for (const title of [
|
||||
"Konto",
|
||||
"Tracking",
|
||||
"Darstellung",
|
||||
"Integrationen",
|
||||
"Benachrichtigungen",
|
||||
"KI & Coach",
|
||||
"API & Zugriff",
|
||||
"Daten & Datenschutz",
|
||||
"Über",
|
||||
]) {
|
||||
expect(html, title).toContain(title);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
|
||||
it("does NOT surface a raw i18n key — guards against missing JSON entries", () => {
|
||||
const html = renderShell({ active: "account", locale: "en" });
|
||||
expect(html).not.toContain("settings.groups.");
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
expect(html).not.toContain("settings.shell.");
|
||||
});
|
||||
|
||||
it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
|
||||
// The 10-section strip is wider than 393 CSS px, so without
|
||||
// `no-scrollbar` Chromium/WebKit paints an always-on horizontal
|
||||
// scrollbar at the top of every settings page. The class is
|
||||
// defined in `globals.css` and combines `scrollbar-width: none`
|
||||
// (Firefox) with `::-webkit-scrollbar { display: none }` (everyone
|
||||
// else) — scroll behaviour is preserved.
|
||||
it("emits the section heading `id` on exactly ONE instance (no duplicate-id-aria)", () => {
|
||||
// v1.25.1 (A11Y M1) — the heading paints at two breakpoints (mobile above
|
||||
// the strip, desktop in the grid); both stay in the DOM. The `id` (the
|
||||
// `aria-labelledby` target) must appear once, while `data-settings-heading`
|
||||
// (the focus-effect hook) stays on both instances.
|
||||
const html = renderShell({ active: "account" });
|
||||
expect(count(html, /id="settings-section-account-title"/g)).toBe(1);
|
||||
expect(
|
||||
count(html, /data-settings-heading="settings-section-account-title"/g),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("mobile group strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>/);
|
||||
expect(nav).not.toBeNull();
|
||||
|
||||
@@ -350,6 +350,19 @@ export function AboutMeSection({
|
||||
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
|
||||
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
||||
</p>
|
||||
|
||||
{/* v1.25.1 — cross-link to the Anamnese (medical-history) home. The
|
||||
chronic conditions noted above plus allergies + family history read as
|
||||
one coherent medical history there. */}
|
||||
<p className="text-muted-foreground pl-7 text-xs">
|
||||
{t("settings.ai.aboutMe.recordsLink")}{" "}
|
||||
<Link
|
||||
href="/settings/anamnesis"
|
||||
className="text-dracula-purple underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("settings.ai.aboutMe.recordsLinkCta")}
|
||||
</Link>
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.43 QoL (M3) — separate destructive action for the full account
|
||||
* delete. Pre-fix, the only "danger zone" CTA wiped the user's health
|
||||
* data but left the user row, passkeys, audit log, and sessions
|
||||
* intact — half of what a user reading "Danger Zone" expects per
|
||||
* GDPR Article 17. The route `DELETE /api/settings/account` already
|
||||
* cascades User + passkeys + audit log + sessions (and is rate-limited
|
||||
* by the API handler); this card is its UI front door.
|
||||
*
|
||||
* Visually the card mirrors the data-reset shaping (neutral title,
|
||||
* red CTA only) but the dialog copy is explicit: this deletes the
|
||||
* account, not just the data, and the user will be signed out
|
||||
* immediately.
|
||||
*
|
||||
* v1.25.1 (Q2-M3) — moved out of Settings → Advanced (Data & Privacy group)
|
||||
* into the Account group. Deleting your account is an account-lifecycle
|
||||
* action, not a data/privacy control, so it belongs where a user looks for
|
||||
* "my account": next to the profile + sign-in surfaces. Research mode + the
|
||||
* data reset stay in Advanced.
|
||||
*/
|
||||
export function AccountDeleteCard() {
|
||||
const { t } = useTranslations();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.deleteAccountFailed"));
|
||||
} catch {
|
||||
setMsg(t("settings.deleteAccountFailed"));
|
||||
}
|
||||
setMsgType("error");
|
||||
setDeleting(false);
|
||||
return;
|
||||
}
|
||||
setMsg(t("settings.deleteAccountSuccess"));
|
||||
setMsgType("success");
|
||||
// The route destroyed every session before deleting the row;
|
||||
// give the toast a beat to paint, then bounce to the login page.
|
||||
// Leave `deleting` set so the confirm button keeps its pending
|
||||
// state through the redirect — the row is gone, nothing to undo.
|
||||
setTimeout(() => {
|
||||
window.location.href = "/auth/login";
|
||||
}, 1_500);
|
||||
} catch {
|
||||
setMsg(t("settings.deleteAccountFailed"));
|
||||
setMsgType("error");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-account-delete-card">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.deleteAccountCardTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.deleteAccountCardDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the irreversible delete runs so
|
||||
// the pending state stays on screen and a stray dismissal
|
||||
// can't fire a second request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deleteAccountConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deleteAccountConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-account-delete-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.deleteAccountFinal")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -37,8 +37,7 @@ import {
|
||||
type StatusMessage,
|
||||
} from "./account-section-utils";
|
||||
import { AvatarSection } from "./avatar-section";
|
||||
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
|
||||
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
|
||||
import { AccountDeleteCard } from "@/components/settings/account-delete-card";
|
||||
|
||||
export { resolveInitialTimezone } from "./account-section-utils";
|
||||
|
||||
@@ -498,11 +497,17 @@ export function AccountSection() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.23 — active-session management (issue #64) + the security-activity
|
||||
feed. Both sit in the account/security block under the credential
|
||||
controls above. */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
{/* v1.25.1 (H1) — active-session management + trusted devices + the
|
||||
security-activity feed moved to the Account → Security sub-tab, so all
|
||||
"who can sign in as me" controls (2FA, passkeys, recovery, sessions,
|
||||
devices, login activity) live in one place instead of being split
|
||||
across the Profile tab and the Data & Privacy group. */}
|
||||
|
||||
{/* v1.25.1 (Q2-M3) — account deletion lives here now (it was in the
|
||||
Data & Privacy group's Advanced page). Deleting your account is an
|
||||
account-lifecycle action; it sits at the foot of the profile surface
|
||||
as the page's danger zone. */}
|
||||
<AccountDeleteCard />
|
||||
|
||||
{/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings →
|
||||
Erweitert. It is a maintenance / reset action, not a profile or
|
||||
|
||||
@@ -55,11 +55,14 @@ export function AdvancedSection() {
|
||||
// v1.18.6.1 — the guided tour replay card was removed: the tour now exists
|
||||
// only as the first-time auto-start after onboarding. This page is the
|
||||
// research-mode + data/account destructive controls.
|
||||
// v1.25.1 (Q2-M3) — account deletion moved to the Account group
|
||||
// (`<AccountDeleteCard>` now renders under Settings → Profile & preferences).
|
||||
// It is an account-lifecycle action, not a data/privacy control. Research
|
||||
// mode + the data reset stay here.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
<AccountDeleteCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -336,144 +339,3 @@ function DataResetCard() {
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.43 QoL (M3) — separate destructive action for the full account
|
||||
* delete. Pre-fix, the only "danger zone" CTA wiped the user's health
|
||||
* data but left the user row, passkeys, audit log, and sessions
|
||||
* intact — half of what a user reading "Danger Zone" expects per
|
||||
* GDPR Article 17. The route `DELETE /api/settings/account` already
|
||||
* cascades User + passkeys + audit log + sessions (and is rate-limited
|
||||
* by the API handler); this card is its UI front door.
|
||||
*
|
||||
* Visually the card mirrors the data-reset shaping (neutral title,
|
||||
* red CTA only) but the dialog copy is explicit: this deletes the
|
||||
* account, not just the data, and the user will be signed out
|
||||
* immediately.
|
||||
*/
|
||||
function AccountDeleteCard() {
|
||||
const { t } = useTranslations();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.deleteAccountFailed"));
|
||||
} catch {
|
||||
setMsg(t("settings.deleteAccountFailed"));
|
||||
}
|
||||
setMsgType("error");
|
||||
setDeleting(false);
|
||||
return;
|
||||
}
|
||||
setMsg(t("settings.deleteAccountSuccess"));
|
||||
setMsgType("success");
|
||||
// The route destroyed every session before deleting the row;
|
||||
// give the toast a beat to paint, then bounce to the login page.
|
||||
// Leave `deleting` set so the confirm button keeps its pending
|
||||
// state through the redirect — the row is gone, nothing to undo.
|
||||
setTimeout(() => {
|
||||
window.location.href = "/auth/login";
|
||||
}, 1_500);
|
||||
} catch {
|
||||
setMsg(t("settings.deleteAccountFailed"));
|
||||
setMsgType("error");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-account-delete-card">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
{t("settings.deleteAccountCardTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.deleteAccountCardDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Hold the dialog open while the irreversible delete runs so
|
||||
// the pending state stays on screen and a stray dismissal
|
||||
// can't fire a second request.
|
||||
if (deleting) return;
|
||||
setConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleting}
|
||||
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
||||
data-slot="settings-account-delete-trigger"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.deleteAccountCta")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deleteAccountConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deleteAccountConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleDeleteAccount();
|
||||
}}
|
||||
disabled={deleting}
|
||||
aria-busy={deleting || undefined}
|
||||
data-slot="settings-account-delete-confirm"
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.deleteAccountFinal")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
* card. These are patient-reported reference records — not a time-series
|
||||
* signal and not a clinical diagnosis — surfaced alongside the existing
|
||||
* tracking-domain settings sections (Labs / Illness / Vorsorge).
|
||||
*
|
||||
* v1.25.1 — the section also surfaces the pre-existing / chronic conditions the
|
||||
* Coach watches (the self-context entered under Profile → "About me") as a
|
||||
* read-only shared view, so conditions + allergies + family history read as one
|
||||
* coherent medical history. The conditions card is coach-gated (the data only
|
||||
* feeds the Coach) and links back to its single editing home in personal
|
||||
* context.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -18,14 +25,31 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
|
||||
import { AllergyManager } from "@/components/records/allergy-manager";
|
||||
import { ConditionsOverview } from "@/components/records/conditions-overview";
|
||||
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
|
||||
|
||||
export function AnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
const coachEnabled = useModuleEnabled("coach");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{coachEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.conditions.cardTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("records.conditions.cardDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConditionsOverview />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
|
||||
|
||||
@@ -29,9 +29,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
|
||||
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
|
||||
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -217,10 +214,25 @@ export function PrivacySection() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Active sessions + trusted devices + security activity (embedded) */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
{/* v1.25.1 (H1) — active sessions, trusted devices, and the
|
||||
security-activity feed moved to Account → Security so all "who can
|
||||
sign in as me" controls sit in one place. They are not re-embedded
|
||||
here; this pane keeps the data / retention / encryption story. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.privacy.signIn.title")}
|
||||
description={t("settings.privacy.signIn.description")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="pl-7">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/settings/security">
|
||||
{t("settings.privacy.signIn.openSecurity")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,15 @@
|
||||
* shared `GET /api/auth/me/security-activity` endpoint. Each row shows a
|
||||
* readable action label, the resolved location, the masked IP, and the time.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldCheck } from "lucide-react";
|
||||
import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActivityRow {
|
||||
action: string;
|
||||
@@ -53,6 +54,10 @@ export function SecurityActivityCard({
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// Collapsed by default — the feed opens only on demand, keeping the security
|
||||
// surface skimmable. UI-only state; nothing is persisted across reloads.
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionId = useId();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.securityActivity(),
|
||||
@@ -65,12 +70,32 @@ export function SecurityActivityCard({
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-security-activity-card">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.security.activityTitle")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="space-y-4 pl-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="settings-security-activity-toggle"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ShieldCheck
|
||||
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.security.activityTitle")}
|
||||
</h2>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<div id={regionId} hidden={!open} className="space-y-4 pl-7">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.activityDescription")}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* v1.25.1 (H1) — sign-in management consolidation.
|
||||
*
|
||||
* Active sessions, trusted devices, and the login-activity feed moved out of
|
||||
* the Data & Privacy group into Account → Security so every "who can sign in as
|
||||
* me" control sits in one place. This SSR test pins that the three cards render
|
||||
* inside `<SecuritySection>`.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, isLoading: false }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SecuritySection } from "../index";
|
||||
|
||||
function render() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Seed the MFA status so the second-factor cards paint instead of skeletons.
|
||||
client.setQueryData(queryKeys.mfaStatus(), {
|
||||
totp: { enabled: false },
|
||||
recoveryCodesRemaining: 0,
|
||||
webauthn: [],
|
||||
passkeyNudgeDismissed: true,
|
||||
});
|
||||
client.setQueryData(queryKeys.passkeys(), []);
|
||||
// Seed the embedded sign-in reads so the cards paint without firing network.
|
||||
client.setQueryData(queryKeys.sessions(), { sessions: [] });
|
||||
client.setQueryData(queryKeys.securityActivity(), { events: [] });
|
||||
client.setQueryData(queryKeys.trustedDevices(), {
|
||||
devices: [
|
||||
{
|
||||
id: "td-1",
|
||||
label: "Firefox on macOS",
|
||||
isCurrent: true,
|
||||
createdAt: "2099-01-01T00:00:00.000Z",
|
||||
lastUsedAt: "2099-01-01T00:00:00.000Z",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>
|
||||
<SecuritySection />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SecuritySection> — sign-in management home (H1)", () => {
|
||||
it("renders the active-session, trusted-device, and security-activity cards", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="settings-security-sessions-card"');
|
||||
expect(html).toContain('data-slot="settings-trusted-devices-card"');
|
||||
expect(html).toContain('data-slot="settings-security-activity-card"');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@ import { TotpCard } from "./totp-card";
|
||||
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
|
||||
import { PasskeyListSection } from "./passkey-list-section";
|
||||
import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge";
|
||||
// v1.25.1 (H1) — active sessions, trusted devices, and the login-activity feed
|
||||
// are "who/what can sign in as me" — the same mental model as the second-factor
|
||||
// and passkey controls above. They used to live in the Data & Privacy group
|
||||
// (split across two top-level groups); they now sit here in Account → Security
|
||||
// as the single sign-in-management home.
|
||||
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
|
||||
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
|
||||
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
|
||||
|
||||
interface MfaStatus {
|
||||
totp: { enabled: boolean };
|
||||
@@ -45,28 +53,40 @@ export function SecuritySection() {
|
||||
passkeys != null &&
|
||||
passkeys.length === 0;
|
||||
|
||||
if (isLoading || !status) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
// The second-factor / passkey cards depend on the MFA status payload, so they
|
||||
// skeleton while it loads. The session / device / activity cards own their own
|
||||
// reads and loading states, so they render unconditionally below — a slow or
|
||||
// failed `/api/auth/me/mfa` must never hide active-session revocation.
|
||||
const mfaCards =
|
||||
isLoading || !status ? (
|
||||
<>
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
{mfaCards}
|
||||
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
{/* v1.25.1 (H1) — sign-in management consolidated here. */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,16 @@
|
||||
* session (and native device logins) while keeping the current one. All reads
|
||||
* + writes route through the centralised query-key factory.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MonitorSmartphone } from "lucide-react";
|
||||
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
@@ -38,6 +38,11 @@ export function SecuritySessionsCard({
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
// Collapsed by default — the list opens only when the user asks for it, so
|
||||
// the security surface stays calm and skimmable on first paint. UI-only
|
||||
// state; nothing is persisted across reloads.
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionId = useId();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.sessions(),
|
||||
@@ -79,12 +84,32 @@ export function SecuritySessionsCard({
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-security-sessions-card">
|
||||
<SettingsCardHeader
|
||||
icon={MonitorSmartphone}
|
||||
title={t("settings.security.sessionsTitle")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="space-y-4 pl-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="settings-security-sessions-toggle"
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<MonitorSmartphone
|
||||
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.security.sessionsTitle")}
|
||||
</h2>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<div id={regionId} hidden={!open} className="space-y-4 pl-7">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.security.sessionsDescription")}
|
||||
</p>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.17.1 (N-5) — the up-affordance for a hub child editor.
|
||||
*
|
||||
* The four Layout personalization editors (dashboard / insights /
|
||||
* medications / mood) are reached through the "Layout & Personalization"
|
||||
* hub (or a page-header cog), and the left rail highlights the hub while
|
||||
* the body shows the child. The return path existed only implicitly (tap
|
||||
* the highlighted rail entry); this is the explicit "← back to hub" link
|
||||
* at the top of the child, so the hub → child → hub loop reads clearly,
|
||||
* especially on mobile where the rail collapses to a chip strip.
|
||||
*/
|
||||
export function SettingsHubBackLink({
|
||||
href,
|
||||
labelKey,
|
||||
}: {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground -mx-1 inline-flex w-fit items-center gap-1 rounded-md px-1 py-0.5 text-xs font-medium transition-colors",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "./section-slugs";
|
||||
import { SettingsHubBackLink } from "./settings-hub-back-link";
|
||||
|
||||
// Re-export so existing call-sites importing from this module keep working.
|
||||
// New server-side call-sites (e.g. `generateStaticParams()`) should import
|
||||
@@ -289,6 +288,142 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* v1.25.1 — Settings information-architecture consolidation.
|
||||
*
|
||||
* The left rail used to render one entry per section (26 visible). It now
|
||||
* renders NINE top-level GROUPS. Every section route stays live at its own
|
||||
* URL — `generateStaticParams()` still emits every slug, deep links and the
|
||||
* per-page settings cogs still resolve — but the sidebar is driven by this
|
||||
* group model and each group page surfaces its children as in-page sub-tabs.
|
||||
*
|
||||
* The mechanism generalises the old `LAYOUT_CHILD_SLUGS` + `navHighlightSlug`
|
||||
* trick (a live route, hidden from the rail, highlighting a parent): every
|
||||
* child slug now maps to its group for rail highlighting, and the group page
|
||||
* paints a sub-tab strip across its visible children.
|
||||
*
|
||||
* Two levels everywhere — the rail is one level, the sub-tabs are the second,
|
||||
* no child of a child.
|
||||
*/
|
||||
export type SettingsGroupId =
|
||||
| "account"
|
||||
| "tracking"
|
||||
| "display"
|
||||
| "integrations"
|
||||
| "notifications"
|
||||
| "ai"
|
||||
| "access"
|
||||
| "data"
|
||||
| "about";
|
||||
|
||||
export interface SettingsGroup {
|
||||
id: SettingsGroupId;
|
||||
/** i18n key under `settings.groups.<id>.title`. */
|
||||
titleKey: string;
|
||||
icon: LucideIcon;
|
||||
/**
|
||||
* Ordered child section slugs. Rendered as in-page sub-tabs (filtered by
|
||||
* module gate) when more than one is visible. The first VISIBLE child is
|
||||
* the group's landing route — the rail entry links there so a click never
|
||||
* lands on a module-gated-off page.
|
||||
*/
|
||||
children: readonly SettingsSectionSlug[];
|
||||
}
|
||||
|
||||
export const SETTINGS_GROUPS: readonly SettingsGroup[] = [
|
||||
{
|
||||
id: "account",
|
||||
titleKey: "settings.groups.account.title",
|
||||
icon: User,
|
||||
children: ["account", "security"],
|
||||
},
|
||||
{
|
||||
id: "tracking",
|
||||
titleKey: "settings.groups.tracking.title",
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
"modules",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
"anamnesis",
|
||||
"environment",
|
||||
"thresholds",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
titleKey: "settings.groups.display.title",
|
||||
icon: LayoutDashboard,
|
||||
// `layout` (the personalisation hub) stays a live route and highlights
|
||||
// this group, but it is not a sub-tab — Dashboard + Insights are the
|
||||
// tabs. See `SLUG_TO_GROUP` below for the `layout` alias.
|
||||
children: ["dashboard", "insights"],
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
titleKey: "settings.groups.integrations.title",
|
||||
icon: Link2,
|
||||
// `channels` (delivery channels) stays under Integrations by deliberate
|
||||
// choice (Q2-M1): a channel is a delivery PROVIDER, which the standing
|
||||
// "providers → Integrations" principle places here, not under Notifications
|
||||
// (which owns reminder TYPES). Reconsidered and kept as status quo in
|
||||
// v1.25.1 — do not move it to the Notifications group.
|
||||
children: ["integrations", "channels", "sources"],
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
titleKey: "settings.groups.notifications.title",
|
||||
icon: Bell,
|
||||
children: ["notifications"],
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
titleKey: "settings.groups.ai.title",
|
||||
icon: Sparkles,
|
||||
children: ["ai", "coach"],
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
titleKey: "settings.groups.access.title",
|
||||
icon: KeyRound,
|
||||
children: ["api", "mcp"],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
titleKey: "settings.groups.data.title",
|
||||
icon: Lock,
|
||||
children: ["export", "gesundheitsakte", "sharing", "privacy", "advanced"],
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
titleKey: "settings.groups.about.title",
|
||||
icon: Info,
|
||||
children: ["about"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Reverse index: every section slug → its owning group id. Built from
|
||||
* `SETTINGS_GROUPS.children`, plus the `layout` alias (the personalisation
|
||||
* hub route lives under Display but is not one of its sub-tabs).
|
||||
*/
|
||||
const SLUG_TO_GROUP: Record<string, SettingsGroupId> = (() => {
|
||||
const map: Record<string, SettingsGroupId> = {};
|
||||
for (const group of SETTINGS_GROUPS) {
|
||||
for (const child of group.children) map[child] = group.id;
|
||||
}
|
||||
map["layout"] = "display";
|
||||
return map;
|
||||
})();
|
||||
|
||||
/** Per-slug metadata lookup (icon / titleKey / moduleGate) for sub-tabs. */
|
||||
const SECTION_BY_SLUG: ReadonlyMap<string, SettingsSection> = new Map(
|
||||
SETTINGS_SECTIONS.map((section) => [section.slug, section]),
|
||||
);
|
||||
|
||||
export interface SettingsShellProps {
|
||||
/**
|
||||
* Optional override for the active section. When omitted the shell reads
|
||||
@@ -321,38 +456,40 @@ export interface SettingsShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// v1.17.1 (F-2) — the personalization editors live under the Layout
|
||||
// hub. They keep their own routes but are NOT standalone nav entries, so
|
||||
// when the user is on one of them the Layout nav entry is the one that
|
||||
// reads active.
|
||||
//
|
||||
// v1.18.0 (S5) — `mood` (Stimmung) and `medications` (Medikamente)
|
||||
// graduated to their own nav entries, so they are no longer Layout-hub
|
||||
// children for highlighting purposes.
|
||||
const LAYOUT_CHILD_SLUGS: ReadonlySet<string> = new Set([
|
||||
"dashboard",
|
||||
"insights",
|
||||
]);
|
||||
|
||||
/** Map a Layout child editor onto the Layout hub for nav highlighting. */
|
||||
function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug {
|
||||
return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active section slug from the route (or an explicit override the
|
||||
* page passes). Unlike the pre-v1.25.1 shell this no longer remaps a child
|
||||
* onto a parent — the real slug is kept so the heading + active sub-tab are
|
||||
* correct. Group highlighting is derived separately via `SLUG_TO_GROUP`.
|
||||
*/
|
||||
function deriveActiveSlug(
|
||||
pathname: string | null,
|
||||
override?: SettingsSectionSlug,
|
||||
): SettingsSectionSlug {
|
||||
if (override) return navHighlightSlug(override);
|
||||
if (override) return override;
|
||||
if (!pathname) return "account";
|
||||
// Match `/settings/<slug>` and ignore any trailing segments (none today,
|
||||
// but cheap insurance for future nested routes).
|
||||
const match = pathname.match(/^\/settings\/([^/]+)/);
|
||||
const candidate = match?.[1] ?? "";
|
||||
if (LAYOUT_CHILD_SLUGS.has(candidate)) return "layout";
|
||||
return isSettingsSectionSlug(candidate) ? candidate : "account";
|
||||
}
|
||||
|
||||
/** Map any section slug onto its owning group (defaults to Account). */
|
||||
function deriveActiveGroup(slug: SettingsSectionSlug): SettingsGroupId {
|
||||
return SLUG_TO_GROUP[slug] ?? "account";
|
||||
}
|
||||
|
||||
/** Is a slug visible given the user's module map? Fails OPEN. */
|
||||
function isSlugVisible(
|
||||
slug: SettingsSectionSlug,
|
||||
modules: Record<string, boolean> | undefined,
|
||||
): boolean {
|
||||
const section = SECTION_BY_SLUG.get(slug);
|
||||
if (!section?.moduleGate) return true;
|
||||
return modules?.[section.moduleGate] !== false;
|
||||
}
|
||||
|
||||
export function SettingsShell({
|
||||
active,
|
||||
heading,
|
||||
@@ -370,12 +507,36 @@ export function SettingsShell({
|
||||
// reads as enabled so an entry never silently disappears. Entries with
|
||||
// no `moduleGate` (global / CORE) are always shown.
|
||||
const modules = user?.modules;
|
||||
const visibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
|
||||
const activeGroupId = deriveActiveGroup(activeSlug);
|
||||
|
||||
// v1.25.1 — the rail renders one entry per GROUP. Each group surfaces its
|
||||
// first VISIBLE child as the landing route, so a tap never lands on a
|
||||
// module-gated-off page. A group shows when it has at least one visible
|
||||
// child; every group carries an always-on child, so all nine always render
|
||||
// (the proposal's "no group fully disappears" guarantee).
|
||||
const groupNav = SETTINGS_GROUPS.map((group) => {
|
||||
const visibleChildren = group.children.filter((slug) =>
|
||||
isSlugVisible(slug, modules),
|
||||
);
|
||||
return { group, visibleChildren, landingSlug: visibleChildren[0] };
|
||||
}).filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
group: SettingsGroup;
|
||||
visibleChildren: SettingsSectionSlug[];
|
||||
landingSlug: SettingsSectionSlug;
|
||||
} => entry.landingSlug !== undefined,
|
||||
);
|
||||
const activeSection = visibleSections.find(
|
||||
(section) => section.slug === activeSlug,
|
||||
|
||||
// Sub-tabs for the active group — painted only when it has 2+ visible
|
||||
// children. `layout` (the Display hub) is not one of Display's tabs, so on
|
||||
// that route no tab reads active and the hub body renders below the strip.
|
||||
const activeGroupEntry = groupNav.find(
|
||||
(entry) => entry.group.id === activeGroupId,
|
||||
);
|
||||
const subTabs: SettingsSectionSlug[] =
|
||||
activeGroupEntry?.visibleChildren ?? [];
|
||||
|
||||
// v1.4.33 IW4 — keep the active chip in view inside the horizontal
|
||||
// mobile strip. On a 393 CSS px viewport the strip is wider than the
|
||||
@@ -411,6 +572,25 @@ export function SettingsShell({
|
||||
strip.scrollTo({ left: target, behavior: "auto" });
|
||||
}, [activeSlug]);
|
||||
|
||||
// v1.25.1 (Q3-M3) — the in-page SUB-TAB strip needs the same
|
||||
// scroll-active-into-view treatment as the group strip above. The Tracking
|
||||
// group renders up to nine sub-tabs in an `overflow-x-auto` strip; deep-linking
|
||||
// (or navigating) to a late tab — thresholds / environment / anamnesis — left
|
||||
// the active `[aria-current="page"]` chip off-screen to the right, reading as
|
||||
// "nothing selected" on a narrow viewport. Pin it to the strip's left edge,
|
||||
// clamped to the scroll range, with the same instant (non-animated) jump.
|
||||
const subTabStripRef = React.useRef<HTMLElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const strip = subTabStripRef.current;
|
||||
if (!strip) return;
|
||||
const active = strip.querySelector<HTMLElement>('[aria-current="page"]');
|
||||
if (!active) return;
|
||||
const maxScroll = strip.scrollWidth - strip.clientWidth;
|
||||
const target = Math.max(0, Math.min(active.offsetLeft, maxScroll));
|
||||
strip.scrollTo({ left: target, behavior: "auto" });
|
||||
}, [activeSlug]);
|
||||
|
||||
// v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading`
|
||||
// (the canonical path); the slug fallback keeps the shell renderable on
|
||||
// its own (and in tests that mount it without a heading) by deriving the
|
||||
@@ -419,44 +599,94 @@ export function SettingsShell({
|
||||
// below.
|
||||
const resolvedHeading =
|
||||
heading ??
|
||||
(activeSection
|
||||
(isSettingsSectionSlug(activeSlug)
|
||||
? {
|
||||
title: t(activeSection.titleKey),
|
||||
subtitle: t(`settings.sections.${activeSection.slug}.subtitle`),
|
||||
headingId: `settings-section-${activeSection.slug}-title`,
|
||||
topSlot: LAYOUT_CHILD_SLUGS.has(activeSection.slug) ? (
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
) : undefined,
|
||||
title: t(`settings.sections.${activeSlug}.title`),
|
||||
subtitle: t(`settings.sections.${activeSlug}.subtitle`),
|
||||
headingId: `settings-section-${activeSlug}-title`,
|
||||
topSlot: undefined,
|
||||
headingAccessory: undefined,
|
||||
}
|
||||
: null);
|
||||
|
||||
const headingBlock = resolvedHeading ? (
|
||||
<div className="space-y-6">
|
||||
{resolvedHeading.topSlot ? <div>{resolvedHeading.topSlot}</div> : null}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
id={resolvedHeading.headingId}
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{resolvedHeading.title}
|
||||
</h1>
|
||||
{resolvedHeading.subtitle ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{resolvedHeading.subtitle}
|
||||
</p>
|
||||
// v1.25.1 (A11Y-L6) — move focus to the section heading when the active
|
||||
// section changes WITHIN the mounted shell (a sub-tab tap or a rail click;
|
||||
// the shell instance is reused across `/settings/[section]` param changes,
|
||||
// which is also why the mobile-strip effect above keys on `activeSlug`).
|
||||
// Without this, keyboard / screen-reader users stay focused on the nav link
|
||||
// they activated and the new content is never announced. The very first
|
||||
// mount (a deep link or a fresh entry into Settings) is skipped on purpose:
|
||||
// auto-focusing on initial page load is itself a jarring, unexpected focus
|
||||
// move. The heading id is shared by two instances (mobile above the strip,
|
||||
// desktop in the grid) — only one is visible per breakpoint — so we focus
|
||||
// the visible one. `preventScroll` leaves the global scroll-reset in charge.
|
||||
const headingId = resolvedHeading?.headingId;
|
||||
const firstHeadingFocusRef = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
if (firstHeadingFocusRef.current) {
|
||||
firstHeadingFocusRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!headingId || typeof document === "undefined") return;
|
||||
const candidates = document.querySelectorAll<HTMLElement>(
|
||||
`[data-settings-heading="${headingId}"]`,
|
||||
);
|
||||
for (const el of candidates) {
|
||||
// `offsetParent === null` ⇒ the element (or an ancestor) is
|
||||
// `display:none` — i.e. the hidden-for-this-breakpoint instance. Skip it
|
||||
// and focus the visible heading.
|
||||
if (el.offsetParent !== null) {
|
||||
el.focus({ preventScroll: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [activeSlug, headingId]);
|
||||
|
||||
// v1.25.1 (A11Y M1) — the heading node is painted at two breakpoints (mobile
|
||||
// above the strip, desktop inside the grid). Both stay in the DOM (CSS
|
||||
// `display:none`, not unmounted), so emitting the `id` on both produced a real
|
||||
// `duplicate-id-aria` violation (the id is the `aria-labelledby` target of the
|
||||
// section body, and any `getElementById` consumer would resolve the hidden
|
||||
// instance). The `id` now lands on ONE instance only (`withId`). The focus
|
||||
// effect targets `data-settings-heading` (kept on both) and picks the visible
|
||||
// one, and `aria-labelledby` still resolves because referenced-element text
|
||||
// counts even when the element is `display:none`.
|
||||
const makeHeadingBlock = (withId: boolean) =>
|
||||
resolvedHeading ? (
|
||||
<div className="space-y-6">
|
||||
{resolvedHeading.topSlot ? <div>{resolvedHeading.topSlot}</div> : null}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
{...(withId ? { id: resolvedHeading.headingId } : {})}
|
||||
data-settings-heading={resolvedHeading.headingId}
|
||||
// v1.25.1 (A11Y-L6) — programmatically focusable so an in-shell
|
||||
// section switch can move focus here (see the `activeSlug` effect).
|
||||
// `tabIndex={-1}` keeps it out of the sequential tab order but lets
|
||||
// `.focus()` land, so keyboard / screen-reader users are placed in
|
||||
// the new section's content instead of being left on the nav link.
|
||||
tabIndex={-1}
|
||||
className="text-2xl font-bold tracking-tight outline-none"
|
||||
>
|
||||
{resolvedHeading.title}
|
||||
</h1>
|
||||
{resolvedHeading.subtitle ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{resolvedHeading.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{resolvedHeading.headingAccessory ? (
|
||||
<div className="shrink-0">{resolvedHeading.headingAccessory}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{resolvedHeading.headingAccessory ? (
|
||||
<div className="shrink-0">{resolvedHeading.headingAccessory}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
) : null;
|
||||
|
||||
// The desktop instance carries the canonical `id`; the mobile instance is
|
||||
// id-less (it keeps `data-settings-heading` for the focus effect).
|
||||
const mobileHeadingBlock = makeHeadingBlock(false);
|
||||
const desktopHeadingBlock = makeHeadingBlock(true);
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
// already, so this inner shell only carries the wider max-width.
|
||||
@@ -468,8 +698,8 @@ export function SettingsShell({
|
||||
{/* v1.18.6.1 — on mobile the heading sits above the chip strip; on
|
||||
desktop it is rendered inside the grid (row 1 / content column) so
|
||||
it does not paint twice. */}
|
||||
{headingBlock ? (
|
||||
<div className="mb-4 md:hidden">{headingBlock}</div>
|
||||
{mobileHeadingBlock ? (
|
||||
<div className="mb-4 md:hidden">{mobileHeadingBlock}</div>
|
||||
) : null}
|
||||
|
||||
{/* Mobile section strip — horizontal scroll, hidden on md+.
|
||||
@@ -492,13 +722,15 @@ export function SettingsShell({
|
||||
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{visibleSections.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
{groupNav.map(({ group, landingSlug }) => {
|
||||
const isActive = group.id === activeGroupId;
|
||||
const Icon = group.icon;
|
||||
return (
|
||||
<li key={section.slug} className="snap-start">
|
||||
<li key={group.id} className="snap-start">
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
href={`/settings/${landingSlug}`}
|
||||
data-slot="settings-group-nav-item"
|
||||
data-group={group.id}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
// v1.4.25 W8 — chip strip is the primary mobile-settings
|
||||
@@ -511,7 +743,7 @@ export function SettingsShell({
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(section.titleKey)}
|
||||
{t(group.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -529,9 +761,9 @@ export function SettingsShell({
|
||||
the `space-y-6` the heading-to-first-card gap used to carry. */}
|
||||
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
|
||||
{/* Heading — desktop only here (mobile renders it above the strip). */}
|
||||
{headingBlock ? (
|
||||
{desktopHeadingBlock ? (
|
||||
<div className="hidden md:col-start-2 md:row-start-1 md:block">
|
||||
{headingBlock}
|
||||
{desktopHeadingBlock}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -552,13 +784,15 @@ export function SettingsShell({
|
||||
className="no-scrollbar hidden max-h-[calc(100dvh-5.5rem)] overflow-y-auto md:sticky md:top-6 md:col-start-1 md:row-start-2 md:block md:self-start"
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{visibleSections.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
{groupNav.map(({ group, landingSlug }) => {
|
||||
const isActive = group.id === activeGroupId;
|
||||
const Icon = group.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<li key={group.id}>
|
||||
<Link
|
||||
href={`/settings/${section.slug}`}
|
||||
href={`/settings/${landingSlug}`}
|
||||
data-slot="settings-group-nav-item"
|
||||
data-group={group.id}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
@@ -568,7 +802,7 @@ export function SettingsShell({
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(section.titleKey)}
|
||||
{t(group.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -593,6 +827,48 @@ export function SettingsShell({
|
||||
already provides the page's single `<main>` landmark and a
|
||||
nested second one is an a11y violation. */}
|
||||
<div className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
|
||||
{/* v1.25.1 — in-page sub-tabs for the active group. Painted only
|
||||
when the group has 2+ visible children (single-child groups —
|
||||
Notifications, About — render no strip). Horizontal scroll keeps
|
||||
the nine-tab Tracking group from overflowing a narrow viewport,
|
||||
and the `subTabStripRef` effect scrolls the active tab into view. A
|
||||
segmented control (shadcn-style) on every breakpoint. Each tab is
|
||||
a real route Link so deep links + the back/forward stack work. */}
|
||||
{subTabs.length > 1 ? (
|
||||
<nav
|
||||
ref={subTabStripRef}
|
||||
aria-label={t("settings.shell.subSectionsNav")}
|
||||
data-slot="settings-subtabs"
|
||||
className="no-scrollbar bg-muted/50 -mx-1 mb-6 flex gap-1 overflow-x-auto rounded-lg p-1"
|
||||
>
|
||||
{subTabs.map((slug) => {
|
||||
const isActive = slug === activeSlug;
|
||||
return (
|
||||
<Link
|
||||
key={slug}
|
||||
href={`/settings/${slug}`}
|
||||
data-slot="settings-subtab"
|
||||
data-subtab-slug={slug}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex min-h-9 items-center rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: // v1.25.1 (A11Y-L3) — inactive tab text over the
|
||||
// `bg-muted/50` strip. `text-muted-foreground` is tuned
|
||||
// for ~AA on the page background; over the tinted strip
|
||||
// the effective ratio drops below 4.5:1 in light mode.
|
||||
// `text-foreground/70` keeps the de-emphasised look but
|
||||
// clears the WCAG AA floor on the strip.
|
||||
"text-foreground/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`settings.sections.${slug}.title`)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Cross-user in-memory cache leak (v1.25.1).
|
||||
*
|
||||
* The root QueryClient is created once and survives every client-side
|
||||
* navigation, so logout→login on the same browser used to leave the previous
|
||||
* account's NON-user-scoped health-data queries (`["measurements"]`,
|
||||
* `["dashboard","snapshot"]`, …) sitting in memory — the next account read
|
||||
* them before any refetch landed. `clearCachesForSessionEnd` drops the entire
|
||||
* in-memory cache at the session boundary; this test pins that guarantee on a
|
||||
* real QueryClient.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
// The offline-cache wipe touches IndexedDB + the SW caches, neither of which
|
||||
// exists in the node test env — mock it to a spy so we can assert it still
|
||||
// runs alongside the in-memory clear without booting a browser.
|
||||
const clearOfflineCachesForSessionEnd = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/pwa/query-persister", () => ({
|
||||
clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(),
|
||||
}));
|
||||
|
||||
// `use-auth` pulls in the typed fetch wrapper, the router, and the i18n
|
||||
// context at module load. None of them matter for the pure session-end
|
||||
// helper, so stub them so the import is side-effect free in the test env.
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiFetchRaw: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { clearCachesForSessionEnd } from "../use-auth";
|
||||
|
||||
afterEach(() => {
|
||||
clearOfflineCachesForSessionEnd.mockClear();
|
||||
});
|
||||
|
||||
describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => {
|
||||
it("drops every in-memory query, including non-auth health data, at the session boundary", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Seed the cache as a logged-in account would leave it: an auth/me entry
|
||||
// plus a non-user-scoped health-data family.
|
||||
queryClient.setQueryData(queryKeys.authMe(), { id: "account-a" });
|
||||
queryClient.setQueryData(["measurements"], [{ id: "m1", value: 72 }]);
|
||||
queryClient.setQueryData(["dashboard", "snapshot"], { weightKg: 80 });
|
||||
|
||||
expect(queryClient.getQueryCache().getAll().length).toBeGreaterThan(0);
|
||||
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
|
||||
// Nothing survives into the next session: the in-memory cache is empty,
|
||||
// so the health-data entries can never render for the next account.
|
||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(0);
|
||||
expect(queryClient.getQueryData(["measurements"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(["dashboard", "snapshot"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(queryKeys.authMe())).toBeUndefined();
|
||||
|
||||
// The persisted IndexedDB + SW caches are still wiped alongside the
|
||||
// in-memory clear — the existing offline-leak guard stays intact.
|
||||
expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+31
-11
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -231,6 +236,30 @@ export function useAuth() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe every client-side cache at a session END so no session ever inherits
|
||||
* another account's data on a shared browser.
|
||||
*
|
||||
* Primary cross-user guard: `queryClient.clear()` drops the entire IN-MEMORY
|
||||
* query cache. On a long-lived SPA the root QueryClient instance outlives every
|
||||
* client-side navigation, and the health-data families
|
||||
* (`["measurements"]`, `["dashboard","snapshot"]`, `["labs", …]`, `["mood", …]`,
|
||||
* `["insights", …]`) are NOT user-scoped — so without this wipe the next account
|
||||
* that logs in on the same browser reads the previous account's cached entries
|
||||
* before any refetch lands. `clear()` also drops `["auth","me"]`, superseding the
|
||||
* former explicit `setQueryData(null)` + `invalidateQueries(["auth"])`.
|
||||
*
|
||||
* Then `clearOfflineCachesForSessionEnd()` wipes the persisted layers — the
|
||||
* IndexedDB query snapshot, the SW offline read-data cache (`healthlog-data-*`),
|
||||
* and the SW page cache (`healthlog-pages-*`, cached navigation HTML). The static
|
||||
* cache (hashed chunks, icons) carries no PII and stays intact to avoid a
|
||||
* needless re-download. Best-effort; never blocks the redirect.
|
||||
*/
|
||||
export function clearCachesForSessionEnd(queryClient: QueryClient): void {
|
||||
queryClient.clear();
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
@@ -241,16 +270,7 @@ export function useLogout() {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(queryKeys.authMe(), null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
|
||||
// Defense-in-depth at the session boundary: wipe every client-side cache
|
||||
// that can hold this account's health data — the IndexedDB query
|
||||
// snapshot, the SW offline read-data cache (`healthlog-data-*`), and the
|
||||
// SW page cache (`healthlog-pages-*`, cached navigation HTML) — so
|
||||
// nothing survives a logout on a shared device. The static cache (hashed
|
||||
// chunks, icons) carries no PII and is left intact to avoid a needless
|
||||
// re-download. Best-effort; never blocks the redirect.
|
||||
void clearOfflineCachesForSessionEnd();
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
router.push("/auth/login");
|
||||
},
|
||||
// v1.16.4 — a network-failed logout used to do nothing at all (the
|
||||
|
||||
@@ -153,6 +153,29 @@ function authoritativeValues(
|
||||
push(w.latest.daysAgo);
|
||||
}
|
||||
}
|
||||
// v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired
|
||||
// into the briefing. Admit their pre-computed figures so the prose may cite
|
||||
// them, same posture as the glucose / labs / workout blocks above.
|
||||
const grip = features.gripStrength;
|
||||
if (grip) {
|
||||
push(grip.latest);
|
||||
push(grip.avg30);
|
||||
push(grip.slope30);
|
||||
}
|
||||
const waist = features.waist;
|
||||
if (waist) {
|
||||
push(waist.latest);
|
||||
push(waist.avg30);
|
||||
push(waist.slope30);
|
||||
push(waist.whtrLatest);
|
||||
}
|
||||
const pain = features.pain;
|
||||
if (pain) {
|
||||
push(pain.latest);
|
||||
push(pain.avg7);
|
||||
push(pain.avg30);
|
||||
push(pain.slope30);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* v1.25.1 — commit-path unit-integrity tests.
|
||||
*
|
||||
* Committing a numeric observation against an EXISTING biomarker must not
|
||||
* silently rewrite the document's stated unit with the catalog's. When the
|
||||
* units disagree the fact is rejected (fail closed) so the user reconciles on
|
||||
* the review screen, exactly like the `unitRequired` guard — never a corrupt
|
||||
* "6.1 mmol/L written as 6.1 mg/dL" reading.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
labResult: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/labs/biomarker-store", () => ({
|
||||
resolveOrMintBiomarker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptFactData: vi.fn(),
|
||||
}));
|
||||
|
||||
import { commitApprovedFact, FactCommitError } from "@/lib/documents/commit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { decryptFactData } from "@/lib/documents/store";
|
||||
import type { ExtractedFact } from "@/generated/prisma/client";
|
||||
|
||||
function observationFact(): ExtractedFact {
|
||||
return {
|
||||
id: "fact-1",
|
||||
factType: "OBSERVATION",
|
||||
dataEncrypted: new Uint8Array(),
|
||||
} as unknown as ExtractedFact;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 6.1,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
// Existing marker stored in mg/dL — the resolver returns it as-is.
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
commitApprovedFact("user-1", observationFact()),
|
||||
).rejects.toMatchObject({ code: "observation.unitMismatch" });
|
||||
await expect(
|
||||
commitApprovedFact("user-1", observationFact()),
|
||||
).rejects.toBeInstanceOf(FactCommitError);
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits when the stated unit matches the marker's unit (case/space-insensitive)", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 95,
|
||||
unit: " mg/DL ",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
});
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-1",
|
||||
} as never);
|
||||
|
||||
const ref = await commitApprovedFact("user-1", observationFact());
|
||||
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" });
|
||||
expect(prisma.labResult.create).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,23 @@ async function commitObservation(
|
||||
referenceHigh: isQualitative ? null : data.referenceHigh,
|
||||
panel: null,
|
||||
});
|
||||
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
|
||||
// minted marker adopts the document's unit, so this only bites when the
|
||||
// stated unit disagrees with the catalog's authoritative one (e.g. a
|
||||
// document states "6.1 mmol/L" against a marker stored in mg/dL). Writing
|
||||
// the stated number under the catalog's unit would corrupt the reading AND
|
||||
// break the "transcribe verbatim" contract — so reject the fact and let the
|
||||
// user reconcile on the review screen, exactly like the `unitRequired` gate.
|
||||
if (!isQualitative) {
|
||||
const stated = (data.unit as string).trim().toLowerCase();
|
||||
const target = biomarker.unit.trim().toLowerCase();
|
||||
if (stated !== target) {
|
||||
throw new FactCommitError(
|
||||
"observation.unitMismatch",
|
||||
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const created = await prisma.labResult.create({
|
||||
data: {
|
||||
userId,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import type {
|
||||
ExtractedFact,
|
||||
ExtractedFactStatus,
|
||||
@@ -35,6 +35,20 @@ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored document's `Bytes` payload back to its raw bytes. The exact
|
||||
* inverse of `encryptDocumentToBytes`: the column holds `encrypt()`-string
|
||||
* UTF-8 bytes whose plaintext is the base64 of the original binary. Throws on a
|
||||
* bad / missing key id (fail-closed) — the caller must treat a throw as "cannot
|
||||
* serve" and never fall back to returning the ciphertext. The original document
|
||||
* is PHI; the owner-scoped download route and the optional extraction action are
|
||||
* the only paths that read it.
|
||||
*/
|
||||
export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
|
||||
const base64 = decrypt(Buffer.from(buf).toString("utf8"));
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
|
||||
* schema stores. The structured clinical values (diagnosis text, lab values,
|
||||
@@ -74,6 +88,7 @@ export function serialiseDocument(
|
||||
return {
|
||||
id: doc.id,
|
||||
kind: doc.kind as InboundDocumentKindValue,
|
||||
title: doc.title,
|
||||
filename: doc.filename,
|
||||
mimeType: doc.mimeType,
|
||||
byteSize: doc.byteSize,
|
||||
@@ -82,6 +97,9 @@ export function serialiseDocument(
|
||||
reportDate: doc.reportDate
|
||||
? doc.reportDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
documentDate: doc.documentDate
|
||||
? doc.documentDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
errorReason: doc.errorReason,
|
||||
factCount: counts.factCount,
|
||||
pendingCount: counts.pendingCount,
|
||||
@@ -111,8 +129,11 @@ export function serialiseDocumentDetail(
|
||||
facts: ExtractedFact[],
|
||||
): InboundDocumentDetailDto {
|
||||
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
|
||||
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
|
||||
// of the document's tally) so the badge matches the list query.
|
||||
const factCount = facts.filter((f) => f.status !== "REJECTED").length;
|
||||
return {
|
||||
...serialiseDocument(doc, { factCount: facts.length, pendingCount }),
|
||||
...serialiseDocument(doc, { factCount, pendingCount }),
|
||||
facts: facts.map(serialiseFact),
|
||||
};
|
||||
}
|
||||
@@ -270,6 +270,51 @@ describe("extractFeatures — v1.22 glucose aggregate block", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.25.1 clinical-depth aggregate blocks", () => {
|
||||
it("emits grip, waist (with WHtR) and pain blocks from their readings", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
measurementRow("GRIP_STRENGTH", 42, 1),
|
||||
measurementRow("GRIP_STRENGTH", 40, 20),
|
||||
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
|
||||
measurementRow("WAIST_CIRCUMFERENCE", 92, 25),
|
||||
measurementRow("PAIN_NRS", 4, 1),
|
||||
measurementRow("PAIN_NRS", 2, 10),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
|
||||
expect(f.gripStrength).toBeDefined();
|
||||
expect(f.gripStrength?.latest).toBe(42);
|
||||
|
||||
expect(f.waist).toBeDefined();
|
||||
expect(f.waist?.latest).toBe(90);
|
||||
// heightCm is 180 in the default user mock → WHtR = 90 / 180 = 0.5.
|
||||
expect(f.waist?.whtrLatest).toBe(0.5);
|
||||
|
||||
expect(f.pain).toBeDefined();
|
||||
expect(f.pain?.latest).toBe(4);
|
||||
});
|
||||
|
||||
it("omits the clinical-depth blocks when there are no readings", async () => {
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.gripStrength).toBeUndefined();
|
||||
expect(f.waist).toBeUndefined();
|
||||
expect(f.pain).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves WHtR null when height is unknown", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: new Date("1980-01-01"),
|
||||
gender: "MALE",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
|
||||
]);
|
||||
const f = await extractFeatures("user-1", false);
|
||||
expect(f.waist?.whtrLatest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — v1.22 labs briefing block", () => {
|
||||
it("surfaces an abnormal (out-of-range) biomarker", async () => {
|
||||
prismaMock.labResult.findMany.mockResolvedValue([
|
||||
|
||||
@@ -89,6 +89,89 @@ describe("lookupNormalRange — the age/sex reference-range enabler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("grip strength — age × sex bands (Dodds 2014 / EWGSOP2 floor)", () => {
|
||||
it("steps the band down with age (younger reads against a higher norm)", () => {
|
||||
// Male 30s centre 34.5 → {38,51}; male 70s centre 74.5 → {29,39}.
|
||||
const young = lookupNormalRange("GRIP_STRENGTH", 34.5, "MALE")!;
|
||||
const old = lookupNormalRange("GRIP_STRENGTH", 74.5, "MALE")!;
|
||||
expect(young).toEqual({ low: 38, high: 51 });
|
||||
expect(old).toEqual({ low: 29, high: 39 });
|
||||
expect(young.low).toBeGreaterThan(old.low);
|
||||
expect(young.high).toBeGreaterThan(old.high);
|
||||
});
|
||||
|
||||
it("classifies by sex within the same age (male band sits above female)", () => {
|
||||
const male = lookupNormalRange("GRIP_STRENGTH", 44.5, "MALE")!;
|
||||
const female = lookupNormalRange("GRIP_STRENGTH", 44.5, "FEMALE")!;
|
||||
expect(male.low).toBeGreaterThan(female.low);
|
||||
expect(male.high).toBeGreaterThan(female.high);
|
||||
});
|
||||
|
||||
it("never drops the low below the EWGSOP2 cut-off at the oldest band", () => {
|
||||
// 80+ centre 100: male low floored at 27, female low floored at 16.
|
||||
expect(lookupNormalRange("GRIP_STRENGTH", 100, "MALE")!.low).toBe(27);
|
||||
expect(lookupNormalRange("GRIP_STRENGTH", 100, "FEMALE")!.low).toBe(16);
|
||||
// Even past the oldest centre the clamp holds the floor.
|
||||
expect(lookupNormalRange("GRIP_STRENGTH", 110, "MALE")!.low).toBe(27);
|
||||
});
|
||||
|
||||
it("falls back to the sex cut-off band when age is absent", () => {
|
||||
expect(lookupNormalRange("GRIP_STRENGTH", null, "MALE")).toEqual({
|
||||
low: 27,
|
||||
high: 60,
|
||||
});
|
||||
expect(lookupNormalRange("GRIP_STRENGTH", undefined, "FEMALE")).toEqual({
|
||||
low: 16,
|
||||
high: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when sex is absent (no honest sex-specific band)", () => {
|
||||
expect(lookupNormalRange("GRIP_STRENGTH", 40, null)).toBeNull();
|
||||
expect(lookupNormalRange("GRIP_STRENGTH", null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("waist circumference — age × sex bands (WHO, raised for elderly)", () => {
|
||||
it("holds the WHO threshold through midlife, raising it for older adults", () => {
|
||||
// Male 18-49 centre 33.5 → {0,94}; 80+ centre 100 → {0,102}.
|
||||
const adult = lookupNormalRange("WAIST_CIRCUMFERENCE", 33.5, "MALE")!;
|
||||
const elderly = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "MALE")!;
|
||||
expect(adult).toEqual({ low: 0, high: 94 });
|
||||
expect(elderly).toEqual({ low: 0, high: 102 });
|
||||
expect(elderly.high).toBeGreaterThan(adult.high);
|
||||
});
|
||||
|
||||
it("classifies by sex (the female threshold sits below the male)", () => {
|
||||
const male = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "MALE")!;
|
||||
const female = lookupNormalRange("WAIST_CIRCUMFERENCE", 100, "FEMALE")!;
|
||||
expect(female.high).toBeLessThan(male.high);
|
||||
expect(female).toEqual({ low: 0, high: 88 });
|
||||
});
|
||||
|
||||
it("never lowers the threshold below the standard WHO floor", () => {
|
||||
for (const age of [18, 25, 40, 55, 65, 75, 90]) {
|
||||
expect(
|
||||
lookupNormalRange("WAIST_CIRCUMFERENCE", age, "MALE")!.high,
|
||||
).toBeGreaterThanOrEqual(94);
|
||||
expect(
|
||||
lookupNormalRange("WAIST_CIRCUMFERENCE", age, "FEMALE")!.high,
|
||||
).toBeGreaterThanOrEqual(80);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the standard WHO threshold when age is absent", () => {
|
||||
expect(lookupNormalRange("WAIST_CIRCUMFERENCE", null, "MALE")).toEqual({
|
||||
low: 0,
|
||||
high: 94,
|
||||
});
|
||||
expect(lookupNormalRange("WAIST_CIRCUMFERENCE", null, "FEMALE")).toEqual({
|
||||
low: 0,
|
||||
high: 80,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSharpenedNorm", () => {
|
||||
it("mirrors lookupNormalRange truthiness", () => {
|
||||
expect(hasSharpenedNorm("VO2_MAX", 35, "MALE")).toBe(true);
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
* "ATS Statement: Guidelines for the Six-Minute Walk Test",
|
||||
* Am J Respir Crit Care Med 166(1):111–117. Surfaced as a
|
||||
* percent-of-predicted re-frame, never a HealthLog-derived equation.
|
||||
* - Grip strength by age × sex — Dodds et al. 2014, "Grip Strength
|
||||
* across the Life Course: Normative Data from Twelve British Studies",
|
||||
* PLOS ONE 9(12):e113637 (n≈49,964). The age×sex band low is the 10th
|
||||
* centile, FLOORED at the EWGSOP2 "probable sarcopenia" cut-off
|
||||
* (Cruz-Jentoft et al., Age Ageing 2019: men < 27 kg, women < 16 kg) so
|
||||
* the clinical floor is never crossed; the band high is the 50th-centile
|
||||
* (median) for the age×sex cell.
|
||||
* - Waist circumference by age × sex — WHO increased-risk thresholds
|
||||
* (WHO 2008/2011; men > 94 cm, women > 80 cm) as the adult floor, raised
|
||||
* for older adults: the WHO cut-points derive from cohorts aged 20–74,
|
||||
* and evidence in the elderly supports cut-points set at higher waist
|
||||
* values (toward the WHO substantially-increased-risk line, men > 102 cm /
|
||||
* women > 88 cm). No ethnicity adjustment — HealthLog does not collect
|
||||
* ancestry.
|
||||
*
|
||||
* Client-safe — pure data + a pure lookup, no server imports.
|
||||
*/
|
||||
@@ -54,6 +68,15 @@ interface NormRow {
|
||||
/** Sex this row applies to; `null` = applies to either. */
|
||||
sex: "MALE" | "FEMALE" | null;
|
||||
range: NormRange;
|
||||
/**
|
||||
* Age-INDEPENDENT clinical cut-off band (the EWGSOP2 grip floor / the WHO
|
||||
* waist threshold). It is excluded from the age-interpolation set and used
|
||||
* only as the no-age fallback for a sex-specific table — so a profile with
|
||||
* a known sex but no date of birth still gets the sex cut-off rather than
|
||||
* the coarse sex-flat registry anchor. The age-stratified rows already bake
|
||||
* this floor into their bounds, so it never widens the band for a known age.
|
||||
*/
|
||||
ageIndependent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,28 +125,85 @@ const RESPIRATORY_RATE_NORMS: NormRow[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Grip-strength normal band (kg) by sex — the EWGSOP2 "probable sarcopenia"
|
||||
* cut-off (Cruz-Jentoft et al., Age Ageing 2019): men < 27 kg, women < 16 kg.
|
||||
* The band brackets the at-or-above-cut-off "normal" placement; `direction`
|
||||
* is `higher-better`, so the read flags a value below the low bound. A single
|
||||
* adult bracket per sex — the cut-off is sex-specific, not age-banded — so the
|
||||
* resolver returns it whenever the profile sex is known, even with no age.
|
||||
* Grip-strength bands (kg) by age × sex — Dodds et al. 2014 (PLOS ONE
|
||||
* 9(12):e113637). `direction` is `higher-better`, so the read flags a value
|
||||
* below the low bound. Per age×sex cell: low = the Dodds 10th centile floored
|
||||
* at the EWGSOP2 "probable sarcopenia" cut-off (men 27 kg, women 16 kg;
|
||||
* Cruz-Jentoft et al., Age Ageing 2019) so the clinical floor is never
|
||||
* crossed; high = the Dodds 50th-centile (median). Strength peaks in the 30s
|
||||
* and declines from midlife, so the bands step down with age — a 70-year-old
|
||||
* is no longer measured against a 30-year-old's typical grip. The trailing
|
||||
* `ageIndependent` row is the EWGSOP2 sex cut-off used when the profile has a
|
||||
* sex but no date of birth (preserves the pre-v1.25.1 no-age behaviour).
|
||||
*/
|
||||
const GRIP_STRENGTH_NORMS: NormRow[] = [
|
||||
{ minAge: 0, maxAge: 120, sex: "MALE", range: { low: 27, high: 60 } },
|
||||
{ minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 60 } },
|
||||
{ minAge: 20, maxAge: 29, sex: "MALE", range: { low: 30, high: 40 } },
|
||||
{ minAge: 30, maxAge: 39, sex: "MALE", range: { low: 38, high: 51 } },
|
||||
{ minAge: 40, maxAge: 49, sex: "MALE", range: { low: 38, high: 50 } },
|
||||
{ minAge: 50, maxAge: 59, sex: "MALE", range: { low: 35, high: 48 } },
|
||||
{ minAge: 60, maxAge: 69, sex: "MALE", range: { low: 33, high: 45 } },
|
||||
{ minAge: 70, maxAge: 79, sex: "MALE", range: { low: 29, high: 39 } },
|
||||
{ minAge: 80, maxAge: 120, sex: "MALE", range: { low: 27, high: 32 } },
|
||||
{ minAge: 20, maxAge: 29, sex: "FEMALE", range: { low: 21, high: 28 } },
|
||||
{ minAge: 30, maxAge: 39, sex: "FEMALE", range: { low: 24, high: 31 } },
|
||||
{ minAge: 40, maxAge: 49, sex: "FEMALE", range: { low: 23, high: 31 } },
|
||||
{ minAge: 50, maxAge: 59, sex: "FEMALE", range: { low: 21, high: 29 } },
|
||||
{ minAge: 60, maxAge: 69, sex: "FEMALE", range: { low: 18, high: 27 } },
|
||||
{ minAge: 70, maxAge: 79, sex: "FEMALE", range: { low: 16, high: 24 } },
|
||||
{ minAge: 80, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 19 } },
|
||||
// EWGSOP2 sex cut-off — no-age fallback only (not age-interpolated).
|
||||
{
|
||||
minAge: 18,
|
||||
maxAge: 120,
|
||||
sex: "MALE",
|
||||
range: { low: 27, high: 60 },
|
||||
ageIndependent: true,
|
||||
},
|
||||
{
|
||||
minAge: 18,
|
||||
maxAge: 120,
|
||||
sex: "FEMALE",
|
||||
range: { low: 16, high: 60 },
|
||||
ageIndependent: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Waist-circumference normal band (cm) by sex — the WHO European-origin
|
||||
* increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998): men > 94 cm,
|
||||
* women > 80 cm. `direction` is `lower-better`, so the normal band runs from
|
||||
* 0 up to the sex cut-off. Single adult bracket per sex (sex-specific, not
|
||||
* age-banded). No ethnicity adjustment — HealthLog does not collect ancestry.
|
||||
* Waist-circumference bands (cm) by age × sex — `direction` is `lower-better`,
|
||||
* so the normal band runs from 0 up to the sex cut-off (the `high`). The WHO
|
||||
* increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998: men > 94 cm,
|
||||
* women > 80 cm) is the adult floor and is held flat through midlife. The WHO
|
||||
* cut-points derive from cohorts aged 20–74; evidence in the elderly supports
|
||||
* higher cut-points, so the threshold rises with age toward the WHO
|
||||
* substantially-increased-risk line (men > 102 cm, women > 88 cm). The floor
|
||||
* is never lowered — the standard threshold is the minimum. The trailing
|
||||
* `ageIndependent` row is the standard WHO threshold used when the profile has
|
||||
* a sex but no date of birth. No ethnicity adjustment.
|
||||
*/
|
||||
const WAIST_CIRCUMFERENCE_NORMS: NormRow[] = [
|
||||
{ minAge: 0, maxAge: 120, sex: "MALE", range: { low: 0, high: 94 } },
|
||||
{ minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 80 } },
|
||||
{ minAge: 18, maxAge: 49, sex: "MALE", range: { low: 0, high: 94 } },
|
||||
{ minAge: 50, maxAge: 64, sex: "MALE", range: { low: 0, high: 94 } },
|
||||
{ minAge: 65, maxAge: 79, sex: "MALE", range: { low: 0, high: 98 } },
|
||||
{ minAge: 80, maxAge: 120, sex: "MALE", range: { low: 0, high: 102 } },
|
||||
{ minAge: 18, maxAge: 49, sex: "FEMALE", range: { low: 0, high: 80 } },
|
||||
{ minAge: 50, maxAge: 64, sex: "FEMALE", range: { low: 0, high: 80 } },
|
||||
{ minAge: 65, maxAge: 79, sex: "FEMALE", range: { low: 0, high: 84 } },
|
||||
{ minAge: 80, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 88 } },
|
||||
// Standard WHO threshold — no-age fallback only (not age-interpolated).
|
||||
{
|
||||
minAge: 18,
|
||||
maxAge: 120,
|
||||
sex: "MALE",
|
||||
range: { low: 0, high: 94 },
|
||||
ageIndependent: true,
|
||||
},
|
||||
{
|
||||
minAge: 18,
|
||||
maxAge: 120,
|
||||
sex: "FEMALE",
|
||||
range: { low: 0, high: 80 },
|
||||
ageIndependent: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -135,7 +215,8 @@ const NORM_TABLES: Partial<Record<MetricStatusMetricId, NormRow[]>> = {
|
||||
VO2_MAX: VO2_MAX_NORMS,
|
||||
RESTING_HEART_RATE: RESTING_HEART_RATE_NORMS,
|
||||
RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS,
|
||||
// v1.25 — sex-specific (age-independent) cut-offs.
|
||||
// v1.25.1 — age × sex bands, with a sex-specific clinical cut-off as the
|
||||
// floor + the no-age fallback.
|
||||
GRIP_STRENGTH: GRIP_STRENGTH_NORMS,
|
||||
WAIST_CIRCUMFERENCE: WAIST_CIRCUMFERENCE_NORMS,
|
||||
};
|
||||
@@ -174,6 +255,13 @@ function resolveSexRows(rows: NormRow[], sex: NormSex): NormRow[] | null {
|
||||
* sex; when the profile sex is absent it falls back to the sex-agnostic
|
||||
* rows; a sex-specific-only table with no profile sex yields no honest band.
|
||||
*
|
||||
* Clinical-floor fallback: a table may carry an `ageIndependent` cut-off row
|
||||
* per sex (the EWGSOP2 grip floor / the WHO waist threshold). It is excluded
|
||||
* from age interpolation and returned only when no usable age is present, so a
|
||||
* grip / waist read still classifies against the sex cut-off for a profile
|
||||
* with a known sex but no date of birth. A table with no such row (VO₂max, …)
|
||||
* returns `null` without an age, keeping the flat registry anchor.
|
||||
*
|
||||
* Age handling: the bracket tables are coarse (decade / paediatric bands),
|
||||
* so a fractional age that lands near a bracket edge would otherwise read a
|
||||
* hard step at the boundary (e.g. 39.9 → 30s band, 40.0 → 40s band). Instead
|
||||
@@ -195,30 +283,38 @@ export function lookupNormalRange(
|
||||
const rows = resolveSexRows(table, sex);
|
||||
if (!rows || rows.length === 0) return null;
|
||||
|
||||
// Single band — no neighbour to interpolate against. A sex-specific,
|
||||
// age-independent cut-off (grip strength, waist circumference) resolves
|
||||
// here without an age, so the sex-aware band still applies when the
|
||||
// profile carries sex but no date of birth.
|
||||
if (rows.length === 1) return { ...rows[0].range };
|
||||
// Split the age-independent clinical cut-off (the EWGSOP2 grip floor / the
|
||||
// WHO waist threshold) from the age-stratified bands. The cut-off is never
|
||||
// interpolated — it is the no-age fallback for a sex-specific table.
|
||||
const ageRows = rows.filter((row) => !row.ageIndependent);
|
||||
const floorBand = rows.find((row) => row.ageIndependent)?.range ?? null;
|
||||
|
||||
// Multi-bracket tables are age-banded — without a usable age there is no
|
||||
// honest placement, so the caller keeps the flat registry anchor.
|
||||
// No usable age. A sex-specific clinical cut-off still applies (grip / waist
|
||||
// are flagged against the sex threshold even with no date of birth); a
|
||||
// purely age-banded metric (VO₂max, …) has no honest placement, so the
|
||||
// caller keeps the flat registry anchor.
|
||||
if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) {
|
||||
return null;
|
||||
return floorBand ? { ...floorBand } : null;
|
||||
}
|
||||
|
||||
// No age-stratified rows (a cut-off-only table) — return the cut-off band.
|
||||
if (ageRows.length === 0) return floorBand ? { ...floorBand } : null;
|
||||
|
||||
// Single band — no neighbour to interpolate against.
|
||||
if (ageRows.length === 1) return { ...ageRows[0].range };
|
||||
|
||||
// Clamp below the youngest centre / above the oldest centre: hold the
|
||||
// nearest cited band flat rather than extrapolate past the table.
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
const first = ageRows[0];
|
||||
const last = ageRows[ageRows.length - 1];
|
||||
if (ageYears <= bracketCentre(first)) return { ...first.range };
|
||||
if (ageYears >= bracketCentre(last)) return { ...last.range };
|
||||
|
||||
// Find the two adjacent brackets whose centres bracket the age, then blend
|
||||
// their ranges by the fractional position between the centres.
|
||||
for (let i = 0; i < rows.length - 1; i++) {
|
||||
const lo = rows[i];
|
||||
const hi = rows[i + 1];
|
||||
for (let i = 0; i < ageRows.length - 1; i++) {
|
||||
const lo = ageRows[i];
|
||||
const hi = ageRows[i + 1];
|
||||
const loCentre = bracketCentre(lo);
|
||||
const hiCentre = bracketCentre(hi);
|
||||
if (ageYears >= loCentre && ageYears <= hiCentre) {
|
||||
|
||||
@@ -97,6 +97,45 @@ export interface AggregatedFeatures {
|
||||
slope30: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
/**
|
||||
* v1.25.1 — grip-strength aggregate (kg). A clinical-depth signal captured
|
||||
* and shown on its own detail page with EWGSOP2 sarcopenia bands; this is its
|
||||
* present-and-trend block so the daily briefing can narrate the trajectory.
|
||||
* The sex-aware floor stays at the display edge (`norms.ts`); the briefing
|
||||
* reads the trend. Omitted when no grip readings exist. PHQ-9/GAD-7 stay
|
||||
* excluded — only physical signals reach the narrative.
|
||||
*/
|
||||
gripStrength?: {
|
||||
latest: number | null;
|
||||
avg30: number | null;
|
||||
slope30: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
/**
|
||||
* v1.25.1 — waist aggregate. `latest`/`avg30` are the circumference (cm);
|
||||
* `whtrLatest` is the waist-to-height ratio (NICE keeps it < 0.5), computed
|
||||
* from the freshest circumference and the user's height. Omitted when no
|
||||
* waist readings exist.
|
||||
*/
|
||||
waist?: {
|
||||
latest: number | null;
|
||||
avg30: number | null;
|
||||
slope30: number | null;
|
||||
whtrLatest: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
/**
|
||||
* v1.25.1 — pain aggregate (0–10 NRS, lower-better). Present-and-trend block
|
||||
* so the briefing can flag a sustained or rising pain burden. Omitted when no
|
||||
* pain readings exist.
|
||||
*/
|
||||
pain?: {
|
||||
latest: number | null;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
slope30: number | null;
|
||||
coverage: DataCoverage;
|
||||
};
|
||||
mood?: {
|
||||
scale: string;
|
||||
avg7: number | null;
|
||||
@@ -1292,6 +1331,51 @@ export async function extractFeatures(
|
||||
};
|
||||
}
|
||||
|
||||
// Grip strength (kg) — clinical-depth signal; the briefing narrates the
|
||||
// trajectory, the sex-aware EWGSOP2 floor stays at the display edge.
|
||||
const gripData = byType("GRIP_STRENGTH");
|
||||
if (gripData.length > 0) {
|
||||
const summary = summarize(toDataPoints(gripData));
|
||||
features.gripStrength = {
|
||||
latest: summary.latest,
|
||||
avg30: summary.avg30,
|
||||
slope30: summary.slope30?.slope ?? null,
|
||||
coverage: computeCoverage(gripData, now),
|
||||
};
|
||||
}
|
||||
|
||||
// Waist circumference (cm) + waist-to-height ratio. WHtR is computed from the
|
||||
// freshest circumference and the user's height (the same canonical derivation
|
||||
// the detail page uses); omitted when height is unknown.
|
||||
const waistData = byType("WAIST_CIRCUMFERENCE");
|
||||
if (waistData.length > 0) {
|
||||
const summary = summarize(toDataPoints(waistData));
|
||||
const whtrLatest =
|
||||
user?.heightCm && summary.latest
|
||||
? Math.round((summary.latest / user.heightCm) * 100) / 100
|
||||
: null;
|
||||
features.waist = {
|
||||
latest: summary.latest,
|
||||
avg30: summary.avg30,
|
||||
slope30: summary.slope30?.slope ?? null,
|
||||
whtrLatest,
|
||||
coverage: computeCoverage(waistData, now),
|
||||
};
|
||||
}
|
||||
|
||||
// Pain (0–10 NRS, lower-better) — surface a sustained or rising pain burden.
|
||||
const painData = byType("PAIN_NRS");
|
||||
if (painData.length > 0) {
|
||||
const summary = summarize(toDataPoints(painData));
|
||||
features.pain = {
|
||||
latest: summary.latest,
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
slope30: summary.slope30?.slope ?? null,
|
||||
coverage: computeCoverage(painData, now),
|
||||
};
|
||||
}
|
||||
|
||||
// Sleep Duration
|
||||
//
|
||||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
|
||||
|
||||
@@ -140,7 +140,7 @@ export const MODULE_KEYS = [
|
||||
// tracking. OPT-IN (off by default): a depression / anxiety self-assessment
|
||||
// is at least as sensitive as mood, so it ships dark and the user turns it on
|
||||
// deliberately. The `optIn` marker inverts the per-user default; with it off
|
||||
// the `/insights/mental-wellbeing` surface is hidden from nav and the
|
||||
// the `/mental-wellbeing` surface is hidden from nav and the
|
||||
// assessment routes refuse server-side. Item answers are always encrypted and
|
||||
// always excluded from the AI Coach / MCP regardless of this toggle.
|
||||
"mentalHealth",
|
||||
@@ -313,7 +313,7 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
category: "tracking",
|
||||
// Off by default: a depression / anxiety screener is highly sensitive, so
|
||||
// the surface ships dark and the user opts in deliberately. Turning it on
|
||||
// reveals the `/insights/mental-wellbeing` check-in and lets the screener
|
||||
// reveals the `/mental-wellbeing` check-in and lets the screener
|
||||
// total ride the doctor-report export; item answers stay encrypted and
|
||||
// off the AI Coach / MCP regardless.
|
||||
optIn: true,
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
/**
|
||||
* OpenAPI route table for inbound clinical documents (`/api/documents/inbound/*`).
|
||||
* OpenAPI route table for the documents library (`/api/documents/inbound/*`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The confirm +
|
||||
* edit request bodies reuse the runtime schemas from
|
||||
* edit + list request bodies reuse the runtime schemas from
|
||||
* `@/lib/validations/inbound-documents` so the wire contract stays
|
||||
* single-source. The upload is a `multipart/form-data` binary body (or a JSON
|
||||
* text-mode body); the response shapes are declared here.
|
||||
* single-source.
|
||||
*
|
||||
* Safety contract surfaced in the descriptions: extraction reproduces what the
|
||||
* document states (stated codes/status only) into a staging area — it never
|
||||
* interprets or diagnoses. Nothing reaches the structured stores until the
|
||||
* confirm route commits the user's approvals; a low-confidence fact fails
|
||||
* closed.
|
||||
* The v1.25.x library inverts the original OCR-inbox: upload is STORE-ONLY and
|
||||
* provider-free (a file is always filable, encrypted at rest); AI extraction is
|
||||
* a separate, opt-in action on an already-stored document. Extraction
|
||||
* reproduces what the document states (stated codes/status only) into a staging
|
||||
* area — it never interprets or diagnoses. Nothing reaches the structured
|
||||
* stores until the confirm route commits the user's approvals; a low-confidence
|
||||
* fact fails closed.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
documentUpdateSchema,
|
||||
inboundConfirmSchema,
|
||||
inboundFactEditSchema,
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
INBOUND_DOCUMENT_STATUSES,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS);
|
||||
const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES);
|
||||
|
||||
inboundConfirmSchema.meta({
|
||||
id: "InboundConfirmRequest",
|
||||
@@ -35,6 +42,12 @@ inboundFactEditSchema.meta({
|
||||
"A correction to a staged fact before approval (fixes OCR / units / dates / codes). Discriminated by `factType` so the edit cannot change the fact's resource type. A successful edit clears `needsReview` — the values become user-asserted.",
|
||||
});
|
||||
|
||||
documentUpdateSchema.meta({
|
||||
id: "DocumentUpdateRequest",
|
||||
description:
|
||||
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
|
||||
});
|
||||
|
||||
const factProvenance = z.object({
|
||||
sourceText: z.string(),
|
||||
page: z.number().nullable(),
|
||||
@@ -62,19 +75,15 @@ const extractedFact = z
|
||||
const inboundDocument = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]),
|
||||
kind: kindEnum,
|
||||
title: z.string().nullable(),
|
||||
filename: z.string().nullable(),
|
||||
mimeType: z.string(),
|
||||
byteSize: z.number(),
|
||||
status: z.enum([
|
||||
"EXTRACTING",
|
||||
"EXTRACTED",
|
||||
"FAILED",
|
||||
"CONFIRMED",
|
||||
"DISCARDED",
|
||||
]),
|
||||
status: statusEnum,
|
||||
providerType: z.string().nullable(),
|
||||
reportDate: z.string().nullable(),
|
||||
documentDate: z.string().nullable(),
|
||||
errorReason: z.string().nullable(),
|
||||
factCount: z.number(),
|
||||
pendingCount: z.number(),
|
||||
@@ -84,7 +93,7 @@ const inboundDocument = z
|
||||
.meta({
|
||||
id: "InboundDocument",
|
||||
description:
|
||||
"An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary.",
|
||||
"A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs).",
|
||||
});
|
||||
|
||||
const inboundDocumentDetail = inboundDocument
|
||||
@@ -92,8 +101,15 @@ const inboundDocumentDetail = inboundDocument
|
||||
.meta({ id: "InboundDocumentDetail" });
|
||||
|
||||
const listResponse = z
|
||||
.object({ documents: z.array(inboundDocument) })
|
||||
.meta({ id: "InboundDocumentList" });
|
||||
.object({
|
||||
documents: z.array(inboundDocument),
|
||||
nextCursor: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "InboundDocumentList",
|
||||
description:
|
||||
"A page of documents plus an opaque `nextCursor` (the id to pass back as `cursor` for the next page; null when the list is exhausted).",
|
||||
});
|
||||
|
||||
const confirmResponse = z
|
||||
.object({
|
||||
@@ -114,16 +130,82 @@ const confirmResponse = z
|
||||
"The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation with no unit).",
|
||||
});
|
||||
|
||||
const kindValues = [...INBOUND_DOCUMENT_KINDS];
|
||||
|
||||
export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/documents/inbound": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
summary: "List inbound clinical documents",
|
||||
summary: "List stored documents",
|
||||
description:
|
||||
"The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on the opt-in `inboundDocuments` module.",
|
||||
"The caller's stored documents with title/filename search, category filter, a `documentDate` range, sort, and keyset pagination. Owner-scoped; gated on the opt-in `inboundDocuments` module.",
|
||||
parameters: [
|
||||
{
|
||||
name: "q",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", maxLength: 100 },
|
||||
description: "Case-insensitive search over title + filename.",
|
||||
},
|
||||
{
|
||||
name: "kind",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: kindValues },
|
||||
description: "Category filter.",
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", format: "date" },
|
||||
description: "Inclusive lower bound on documentDate (YYYY-MM-DD).",
|
||||
},
|
||||
{
|
||||
name: "to",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", format: "date" },
|
||||
description: "Inclusive upper bound on documentDate (YYYY-MM-DD).",
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["documentDate", "createdAt", "title"],
|
||||
default: "documentDate",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "order",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["asc", "desc"], default: "desc" },
|
||||
},
|
||||
{
|
||||
name: "cursor",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description: "Keyset cursor (the `nextCursor` from a prior page).",
|
||||
},
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The document list.",
|
||||
description: "A page of documents.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(listResponse, "InboundDocumentListEnvelope"),
|
||||
@@ -135,9 +217,9 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
post: {
|
||||
tags: ["Documents"],
|
||||
summary: "Upload + extract a clinical document",
|
||||
summary: "Store a document (no extraction)",
|
||||
description:
|
||||
'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
"STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB) plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -146,30 +228,26 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
file: z.string().meta({
|
||||
format: "binary",
|
||||
description:
|
||||
"The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
|
||||
"The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
|
||||
}),
|
||||
kind: z
|
||||
.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"])
|
||||
title: z
|
||||
.string()
|
||||
.optional()
|
||||
.meta({ description: "Optional document-type label." }),
|
||||
.meta({ description: "Optional user label." }),
|
||||
kind: kindEnum
|
||||
.optional()
|
||||
.meta({ description: "Optional category." }),
|
||||
documentDate: z.string().optional().meta({
|
||||
description:
|
||||
"Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
"application/json": {
|
||||
schema: z
|
||||
.object({
|
||||
mode: z.literal("text"),
|
||||
text: z.string(),
|
||||
kind: z
|
||||
.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"])
|
||||
.optional(),
|
||||
})
|
||||
.meta({ id: "InboundTextUploadRequest" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "The created document + staging summary.",
|
||||
description: "The stored document.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"),
|
||||
@@ -209,9 +287,41 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Documents"],
|
||||
summary: "Edit a document's metadata",
|
||||
description:
|
||||
"Rename / recategorise / set the user filing date on a stored document. Owner-scoped; no mass assignment. Returns the updated document + its staged facts.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: documentUpdateSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The updated document.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
inboundDocumentDetail,
|
||||
"InboundDocumentUpdateEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Documents"],
|
||||
summary: "Discard an inbound document",
|
||||
summary: "Discard a document",
|
||||
description:
|
||||
"Soft-deletes the document + its staging. Facts already approved into the structured stores are independent rows and are NOT affected.",
|
||||
parameters: [
|
||||
@@ -238,6 +348,86 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/original": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
summary: "View / download the original document",
|
||||
description:
|
||||
"Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served `Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The decrypted original document bytes with the stored MIME type.",
|
||||
content: {
|
||||
"application/pdf": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
"image/*": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/extract": {
|
||||
post: {
|
||||
tags: ["Documents"],
|
||||
summary: "Extract facts from a stored document",
|
||||
description:
|
||||
'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z
|
||||
.object({
|
||||
mode: z.literal("text"),
|
||||
text: z.string(),
|
||||
kind: kindEnum.optional(),
|
||||
})
|
||||
.meta({ id: "InboundTextExtractRequest" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The document with its newly staged facts.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
inboundDocumentDetail,
|
||||
"InboundExtractEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` = `documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and duplicate committed records, so the user must finish reviewing or discard the document first.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/facts/{factId}": {
|
||||
patch: {
|
||||
tags: ["Documents"],
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
/**
|
||||
* Query keys — inbound clinical documents (v1.25, W-DOCS-IN): the document
|
||||
* list and a single document's review detail (document + staged facts).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`. Any inbound
|
||||
* write (upload / edit / confirm / discard) invalidates the `["documents"]`
|
||||
* prefix so the list and the open review repaint in lockstep.
|
||||
* Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable
|
||||
* document list (search / category filter / sort, keyset-paginated) and a
|
||||
* single document's detail (document + staged facts). Part of the centralized
|
||||
* factory; aggregated in `./index.ts`. Any document write (upload / edit /
|
||||
* extract / confirm / discard) invalidates the `["documents"]` prefix so the
|
||||
* list and the open detail repaint in lockstep.
|
||||
*/
|
||||
import type {
|
||||
DocumentListSort,
|
||||
InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* The parameter set that distinguishes one library list view from another.
|
||||
* Lives in the key so two different filter/sort combinations never share a
|
||||
* cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a
|
||||
* bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }`
|
||||
* resolve to the same key.
|
||||
*/
|
||||
export interface DocumentListParams {
|
||||
q?: string;
|
||||
kind?: InboundDocumentKindValue;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort: DocumentListSort;
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
export const documentKeys = {
|
||||
documents: () => ["documents"] as const,
|
||||
inboundDocuments: () => ["documents", "inbound"] as const,
|
||||
/**
|
||||
* The parameterised library list (keyset-paginated via `useInfiniteQuery`).
|
||||
* The literal `"list"` segment keeps it distinct from a single-document key
|
||||
* (`inboundDocument(id)`); cuid ids never collide with it. Stays under the
|
||||
* `["documents", "inbound"]` prefix so prefix invalidation still reaches it.
|
||||
*/
|
||||
inboundDocumentList: (params: DocumentListParams) =>
|
||||
["documents", "inbound", "list", params] as const,
|
||||
inboundDocument: (id: string) => ["documents", "inbound", id] as const,
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { deriveMetricStatusRegistry } from "@/lib/signals/adapters/metric-status";
|
||||
import { deriveMeasurementLoinc } from "@/lib/signals/adapters/fhir";
|
||||
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
|
||||
import expectedMeasurementLoinc from "./fixtures/expected-measurement-loinc.json";
|
||||
import expectedBucketedTypes from "./fixtures/expected-bucketed-types.json";
|
||||
@@ -48,6 +49,22 @@ describe("signal registry — structural sanity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("every biomarker signal's source key resolves to a real catalog slug", () => {
|
||||
// The declared registry↔labs bridge: a `kind: "biomarker"` signal's
|
||||
// `source.biomarkerKey` must name an actual `BIOMARKER_CATALOG` slug. The
|
||||
// length-only check above would let a typo (`"lpa"` vs `"lp-a"`) ride
|
||||
// through; this catches biomarker-slug drift the moment it lands.
|
||||
const catalogSlugs = new Set(BIOMARKER_CATALOG.map((b) => b.slug));
|
||||
for (const signal of allSignals()) {
|
||||
if (signal.kind === "biomarker") {
|
||||
expect(
|
||||
catalogSlugs.has(signal.source.biomarkerKey),
|
||||
`biomarker signal "${signal.key}" → unknown catalog slug "${signal.source.biomarkerKey}"`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every Coach-snapshot signal is also MCP-readable", () => {
|
||||
// `mcp` and `coachSnapshot` are INDEPENDENT facets: a Coach-scoped signal
|
||||
// is always reachable over MCP (the Coach `get_metric_series` path), but a
|
||||
|
||||
@@ -49,10 +49,27 @@ export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number];
|
||||
export const INBOUND_DOCUMENT_KINDS = [
|
||||
"DOCTOR_REPORT",
|
||||
"DISCHARGE_LETTER",
|
||||
"LAB_RESULT",
|
||||
"IMAGING",
|
||||
"PRESCRIPTION",
|
||||
"INSURANCE",
|
||||
"VACCINATION",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number];
|
||||
|
||||
/** The document lifecycle states. STORED is the library default. */
|
||||
export const INBOUND_DOCUMENT_STATUSES = [
|
||||
"STORED",
|
||||
"EXTRACTING",
|
||||
"EXTRACTED",
|
||||
"FAILED",
|
||||
"CONFIRMED",
|
||||
"DISCARDED",
|
||||
] as const;
|
||||
export type InboundDocumentStatusValue =
|
||||
(typeof INBOUND_DOCUMENT_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* The coding systems a fact's STATED code may belong to. The provider emits a
|
||||
* code ONLY when the document writes one — it never machine-guesses. Mirrors
|
||||
@@ -183,14 +200,21 @@ export interface ExtractedFactDto {
|
||||
export interface InboundDocumentDto {
|
||||
id: string;
|
||||
kind: InboundDocumentKindValue;
|
||||
/** User-given title (plaintext), or null when never set. */
|
||||
title: string | null;
|
||||
filename: string | null;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
status: "EXTRACTING" | "EXTRACTED" | "FAILED" | "CONFIRMED" | "DISCARDED";
|
||||
status: InboundDocumentStatusValue;
|
||||
providerType: string | null;
|
||||
/** Model-transcribed report/collection date (YYYY-MM-DD), or null. */
|
||||
reportDate: string | null;
|
||||
/** User-set filing date (YYYY-MM-DD), or null. */
|
||||
documentDate: string | null;
|
||||
errorReason: string | null;
|
||||
/** Count of the document's non-REJECTED staged facts. */
|
||||
factCount: number;
|
||||
/** Count of staged facts still PENDING review. */
|
||||
pendingCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -309,3 +333,88 @@ export const inboundTextExtractSchema = z.object({
|
||||
});
|
||||
|
||||
export type InboundTextExtractInput = z.infer<typeof inboundTextExtractSchema>;
|
||||
|
||||
// ─── Library: store / edit / list ──────────────────────────────────────────
|
||||
|
||||
/** A bare YYYY-MM-DD date string (no time component). */
|
||||
const isoDateString = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected a YYYY-MM-DD date");
|
||||
|
||||
export const DOCUMENT_TITLE_MAX = 200;
|
||||
|
||||
/**
|
||||
* The store-only upload metadata (the multipart form fields beside the file).
|
||||
* Every field is optional — a bare file upload is valid and lands as a STORED
|
||||
* document with no title / category / filing date. No `userId` field; it is
|
||||
* always narrowed from the session.
|
||||
*/
|
||||
export const documentCreateSchema = z.object({
|
||||
title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
documentDate: isoDateString.optional(),
|
||||
});
|
||||
|
||||
export type DocumentCreateInput = z.infer<typeof documentCreateSchema>;
|
||||
|
||||
/**
|
||||
* Metadata edit (rename / recategorise / set the filing date). At least one
|
||||
* field must be present. `title` accepts null to clear it; `documentDate`
|
||||
* accepts null to clear it. No `userId` field — narrowed from the session and
|
||||
* fed to the Prisma `where` alongside the row id.
|
||||
*/
|
||||
export const documentUpdateSchema = z
|
||||
.object({
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(DOCUMENT_TITLE_MAX)
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
documentDate: isoDateString.nullable().optional(),
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
d.title !== undefined ||
|
||||
d.kind !== undefined ||
|
||||
d.documentDate !== undefined,
|
||||
{ message: "Provide at least one field to update" },
|
||||
);
|
||||
|
||||
export type DocumentUpdateInput = z.infer<typeof documentUpdateSchema>;
|
||||
|
||||
/** Library list sort columns + page size. */
|
||||
export const DOCUMENT_LIST_SORTS = [
|
||||
"documentDate",
|
||||
"createdAt",
|
||||
"title",
|
||||
] as const;
|
||||
export type DocumentListSort = (typeof DOCUMENT_LIST_SORTS)[number];
|
||||
|
||||
export const DOCUMENT_LIST_MAX_LIMIT = 100;
|
||||
export const DOCUMENT_LIST_DEFAULT_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* The library list query: title/filename search, category filter, a
|
||||
* `documentDate` range, sort + keyset pagination. Parsed off `searchParams`;
|
||||
* `safeParse` returns 422 on a bad value.
|
||||
*/
|
||||
export const documentListQuerySchema = z.object({
|
||||
q: z.string().trim().max(100).optional(),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
from: isoDateString.optional(),
|
||||
to: isoDateString.optional(),
|
||||
sort: z.enum(DOCUMENT_LIST_SORTS).default("documentDate"),
|
||||
order: z.enum(["asc", "desc"]).default("desc"),
|
||||
cursor: z.string().trim().min(1).max(40).optional(),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(DOCUMENT_LIST_MAX_LIMIT)
|
||||
.default(DOCUMENT_LIST_DEFAULT_LIMIT),
|
||||
});
|
||||
|
||||
export type DocumentListQuery = z.infer<typeof documentListQuerySchema>;
|
||||
Reference in new issue
Block a user