diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5dd6562..44c0f217b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ ## [Unreleased] +## [1.32.39] — 2026-07-26 + +The doctor report now carries what you chose, and nothing else. + +**Ten of the twenty switches did nothing.** The panel offered controls for +oxygen saturation, body fat, body composition, resting heart rate, heart-rate +variability, VO2 max, steps and distance. Switching them off changed nothing: +the value was folded away before it reached the report. Two more claimed less +than they did, and the medication switch left the dose-by-dose record in place +while appearing to remove the drug list. + +**Allergies and family history went out regardless.** They were read straight +from the database in the export route, so a report could exclude them and the +structured file in the same download would still contain them. + +**The mental-health questionnaires went out without being asked for.** The +depression, anxiety, wellbeing and sleep scores reached the structured export, +the automation interface and a shared link, gated only by a module that had +been switched on by default some releases ago. They now have their own entry +and are off unless you pick them. + +**The cycle export shipped ten other things.** The card that promised to export +only cycle data sent an empty request, and an empty request fell back to the +defaults, so it also carried weight, blood pressure, pulse, oxygen saturation, +body fat, the medication list, adherence, lab results, allergies and family +history. The comment above that code said every other section was off. + +**What replaces all of it.** One picker, on both surfaces that choose a scope. +Twelve groups, each releasable or excludable as a whole and openable down to +the individual value. All 77 measurement types and 13 record sections are in +it, each in exactly one group. Sensitive material sits below a divider with no +group control, so no single click can turn on more than one of those at a time. + +Nothing is preselected. A new report starts empty and one button fills in what +a general visit usually needs, which you can then change. Generating is not +possible while nothing is chosen. + +A metric added to the app in future cannot appear in a scope you saved +earlier. That is not a promise, it is a compile error: a new measurement type +without a group breaks the build, and every path that produces a report has to +name where its scope came from. + +**Share links.** A link can now be fetched as a PDF or as a structured file +behind the same passphrase that already protects the view, which is what a +practice actually does with one. The old promise of an interface that never +existed is gone. Links created before this release carry no explicit choice +and are therefore closed; each one can be reissued in a click with its label, +period and expiry carried over. + +**The written summary has left the report.** It could not be checked against +what you chose, and prose whose scope cannot be shown has no place on a +document whose value is exactly that. + +**Structured export corrections.** Someone who records their gender as +something other than female or male was exported as female. FHIR has a code +for it and it is now used, and an unrecognised value is left out rather than +guessed. The capability document described a resource twice and omitted +another that was actually served; both corrected. + +**Also in this release.** Roughly one page load in three showed raw text keys +for a moment instead of words, because the language file was racing the +application bundle and losing. It is now loaded before the first render. + +**For operators.** Migration `0275` stores the new scope and removes the old +preferences. Existing share links are closed by it and need reissuing. + ## [1.32.38] — 2026-07-25 Blood-glucose readings from anything other than the manual entry form were diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d9448a580..f0aa76bef 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.38 + version: 1.32.39 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -871,10 +871,12 @@ paths: tags: - Export summary: Generate a health-record export (PDF / FHIR / package) - description: "v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` → - application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF - + FHIR + README). Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: - unknown keys 422." + description: "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → + application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). + `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and + there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape + is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or + Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422." requestBody: required: true content: @@ -904,69 +906,23 @@ paths: minimum: 1 maximum: 365 additionalProperties: false - sections: + selection: type: object properties: - vitals: - type: object - properties: - weight: - type: boolean - bp: - type: boolean - pulse: - type: boolean - oxygenSaturation: - type: boolean - bodyFat: - type: boolean - bodyComposition: - type: boolean - cardioFitness: - type: object - properties: - restingHeartRate: - type: boolean - hrv: - type: boolean - vo2max: - type: boolean - activity: - type: object - properties: - steps: - type: boolean - distance: - type: boolean - energy: - type: boolean - sleep: - type: boolean - glucose: - type: boolean - medications: - type: object - properties: - list: - type: boolean - compliance: - type: boolean - glp1: - type: boolean - sideEffects: - type: boolean - mood: - type: boolean - bmi: - type: boolean - cycle: - type: boolean - labs: - type: boolean - allergies: - type: boolean - familyHistory: - type: boolean + v: + type: number + const: 2 + leaves: + maxItems: 90 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - v + - leaves + additionalProperties: false locale: type: string enum: @@ -981,12 +937,11 @@ paths: maxLength: 120 includeCharts: type: boolean - includeAiSummary: - type: boolean germanAtc: type: boolean required: - format + - selection additionalProperties: false responses: "200": @@ -1014,14 +969,13 @@ paths: - Export summary: Create a clinician share link (v1.11.0) description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY - ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with - `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase - cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen - write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's - own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports - `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` - is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). - Strict: unknown keys 422." + ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL + fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen + write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the + `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional + `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a + documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at + 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422." requestBody: required: true content: @@ -1043,81 +997,23 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - sections: + selection: type: object properties: - vitals: - type: object - properties: - weight: - type: boolean - bp: - type: boolean - pulse: - type: boolean - oxygenSaturation: - type: boolean - bodyFat: - type: boolean - bodyComposition: - type: boolean - cardioFitness: - type: object - properties: - restingHeartRate: - type: boolean - hrv: - type: boolean - vo2max: - type: boolean - activity: - type: object - properties: - steps: - type: boolean - distance: - type: boolean - energy: - type: boolean - sleep: - type: boolean - glucose: - type: boolean - medications: - type: object - properties: - list: - type: boolean - compliance: - type: boolean - glp1: - type: boolean - sideEffects: - type: boolean - mood: - type: boolean - bmi: - type: boolean - cycle: - type: boolean - labs: - type: boolean - allergies: - type: boolean - familyHistory: - type: boolean - resourceTypes: - maxItems: 8 - type: array - items: - type: string - enum: - - Patient - - Observation - - MedicationStatement - - MedicationAdministration - allowFhirApi: - type: boolean + v: + type: number + const: 2 + leaves: + maxItems: 90 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + required: + - v + - leaves + additionalProperties: false documentIds: maxItems: 50 type: array @@ -1292,8 +1188,9 @@ paths: - FHIR summary: FHIR R4 CapabilityStatement (v1.11.0) description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, - Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the - `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass)." + Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` + interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised + interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)." responses: "200": description: CapabilityStatement (application/fhir+json). @@ -1413,14 +1310,204 @@ paths: "401": *a1 "422": *a3 "429": *a4 - /api/fhir/$everything: + /api/fhir/Coverage: + get: + tags: + - FHIR + summary: FHIR R4 Coverage search + description: "Read-only `searchset` Bundle of the caller's own Coverage resource, when an insurer is recorded and the + insurance leaf is in the saved selection. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / + `_offset`. `userId` is narrowed from auth." + parameters: + - in: query + name: _count + schema: + type: number + - in: query + name: _offset + schema: + type: number + responses: + "200": + description: searchset Bundle (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "422": *a3 + "429": *a4 + /api/fhir/Patient/{id}: + get: + tags: + - FHIR + summary: FHIR R4 Patient read + description: "Read-only `Patient` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/Coverage/{id}: + get: + tags: + - FHIR + summary: FHIR R4 Coverage read + description: "Read-only `Coverage` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/Observation/{id}: + get: + tags: + - FHIR + summary: FHIR R4 Observation read + description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/MedicationStatement/{id}: + get: + tags: + - FHIR + summary: FHIR R4 MedicationStatement read + description: "Read-only `MedicationStatement` by id, out of the caller's own record. An id belonging to someone else is + indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` + scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/MedicationAdministration/{id}: + get: + tags: + - FHIR + summary: FHIR R4 MedicationAdministration read + description: "Read-only `MedicationAdministration` by id, out of the caller's own record. An id belonging to someone + else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: + `fhir:read` scope; `userId` is narrowed from auth." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The resource (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "401": *a1 + "404": + description: OperationOutcome (application/fhir+json). + content: + application/fhir+json: + schema: + type: string + format: binary + "422": *a3 + "429": *a4 + /api/fhir/Patient/$everything: get: tags: - FHIR summary: FHIR R4 $everything (v1.11.0) - description: "Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, - Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` - scope. Offset paging via `_count` (≤200) / `_offset`." + description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, + Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, + Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one + `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. + Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`." parameters: - in: query name: _count @@ -15650,9 +15737,10 @@ components: restBaseUrl: type: string description: Base path of the read-only FHIR R4 REST face. - readScope: - type: string - description: Bearer scope a narrow token needs to read the FHIR face. + scopeMintable: + type: boolean + description: "Whether a narrow Bearer token for the FHIR face can be obtained. Always false: no mint path grants the + scope, so the face is reachable by the owner's cookie session or a wildcard device token only." resourceTypes: type: array items: @@ -15673,7 +15761,7 @@ components: - snomedRoute - germanAtcDefaultLocales - restBaseUrl - - readScope + - scopeMintable - resourceTypes - operations - searchParams @@ -15690,23 +15778,35 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Maximum lifetime of a share link, in days. No never-expiring share. - resourceTypes: + reportDownload: type: array items: type: string - description: FHIR resource types a share link may be scoped to serve. - sections: + description: Machine formats a share link serves as a download under its token, behind the same unlock as the page. + selectionVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Version of the leaf-selection grammar this build speaks. + groups: type: array items: type: string - description: Scopeable report sections a share link may toggle. + description: Selection group ids, in presentation order. + leaves: + type: array + items: + type: string + description: Every leaf id a report selection may carry. Membership is inclusion; absence is exclusion. required: - supported - maxDays - - resourceTypes - - sections + - reportDownload + - selectionVersion + - groups + - leaves additionalProperties: false - description: Clinician share-link surface descriptor (v1.11). + description: Clinician share-link surface descriptor. required: - apiContractVersion - derivedMetricIds @@ -15735,16 +15835,18 @@ components: anyOf: - type: string - type: "null" - resourceTypes: - type: array - items: - type: string - allowFhirApi: - type: boolean protected: type: boolean documentCount: type: number + needsReselection: + type: boolean + allowFhirApi: + type: boolean + resourceTypes: + type: array + items: + type: string expiresAt: type: string createdAt: @@ -15779,10 +15881,11 @@ components: - label - rangeStart - rangeEnd - - resourceTypes - - allowFhirApi - protected - documentCount + - needsReselection + - allowFhirApi + - resourceTypes - expiresAt - createdAt - revokedAt @@ -15827,16 +15930,18 @@ components: anyOf: - type: string - type: "null" - resourceTypes: - type: array - items: - type: string - allowFhirApi: - type: boolean protected: type: boolean documentCount: type: number + needsReselection: + type: boolean + allowFhirApi: + type: boolean + resourceTypes: + type: array + items: + type: string expiresAt: type: string createdAt: @@ -15858,10 +15963,11 @@ components: - label - rangeStart - rangeEnd - - resourceTypes - - allowFhirApi - protected - documentCount + - needsReselection + - allowFhirApi + - resourceTypes - expiresAt - createdAt - revokedAt diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index e2de1e22b..a6ef1db5c 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; @@ -12,9 +14,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * and generic-JSON cards. * 3. The full health-record export panel lives on its own top-level * `/settings/gesundheitsakte` section (v1.18.0 S5) — including the - * "included data" checklist, a disclosure collapsed by default that - * expands on demand. - * 4. Clicking the Measurements CSV download button fires a real + * "included data" disclosure, which a first run opens and which stays + * operable in both directions. + * 4. The scope picker mounts twice on that page (the export panel and the + * share-link create form) and each mount is separately addressable. + * 5. Clicking the Measurements CSV download button fires a real * browser download — proving the `/api/export/measurements` * endpoint is reachable from the browser end-to-end. * @@ -22,6 +26,30 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * download is enough to lock in the wiring without doubling the e2e * runtime. */ + +/** + * Open Settings → Gesundheitsakte and wait until the page is actually live. + * + * Everything on that page is server-rendered, so the rows are visible long + * before the client boundary that owns them has hydrated — a click or a + * `boundingBox()` taken on `domcontentloaded` alone hits inert or replaced + * markup often enough to matter. `GesundheitsakteSection` is one client + * boundary carrying both the export panel and the sharing card, and the + * sharing card's query only runs once that boundary is live, so its request is + * the signal that the whole surface is wired. + */ +async function openGesundheitsakte(page: Page): Promise { + const boundaryHydrated = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/share-links", + ); + await page.goto("/settings/gesundheitsakte", { + waitUntil: "domcontentloaded", + }); + await boundaryHydrated; +} + test.describe("Settings → Export & Import", () => { test.use({ storageState: STORAGE_STATE_PATH }); @@ -45,14 +73,136 @@ test.describe("Settings → Export & Import", () => { }) => { // v1.18.0 (S5) — the health-record export is the hero of the // dedicated, module-gated Gesundheitsakte section. - await page.goto("/settings/gesundheitsakte", { - waitUntil: "domcontentloaded", - }); + await openGesundheitsakte(page); await expect(page.getByTestId("health-record-export-panel")).toBeVisible({ timeout: 10_000, }); }); + test("gives each scope-picker mount its own test ids", async ({ page }) => { + // The Gesundheitsakte page carries two mounts of the same picker: the + // export panel at the top and the share-link create form under `#sharing`. + // Every id inside the picker ends with the surface that mounts it, so an + // assertion binds to one mount instead of resolving to both. + await openGesundheitsakte(page); + await expect(page.getByTestId("report-scope-picker-export")).toHaveCount(1); + await expect(page.getByTestId("report-scope-picker-share")).toHaveCount(1); + for (const id of [ + "report-group-row-vitals", + "report-group-check-vitals", + "report-sensitive-tier", + "report-scope-summary", + ]) { + await expect(page.getByTestId(`${id}-export`)).toHaveCount(1); + await expect(page.getByTestId(`${id}-share`)).toHaveCount(1); + } + }); + + test("opens the scope picker expanded on a first run, fenced tier and all", async ({ + page, + }) => { + // The three panel states are the release's whole point, and on the first + // one nothing is selected at all. The picker still opens without a click, + // because with an empty scope the standard-report button and the named + // groups are exactly what the person needs in front of them. Asserted + // against the export mount; the share form runs its own picker. + await openGesundheitsakte(page); + await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.getByTestId("report-group-row-vitals-export"), + ).toBeVisible(); + await expect( + page.getByTestId("report-group-row-sensitive-export"), + ).toHaveCount(0); + await expect( + page.getByTestId("report-sensitive-tier-export"), + ).toBeVisible(); + // The fenced tier has no group control — there is no single click that + // turns on more than one sensitive leaf. + await expect( + page.getByTestId("report-group-check-sensitive-export"), + ).toHaveCount(0); + await expect(page.getByTestId("report-scope-summary-export")).toBeVisible(); + }); + + test("first run selects nothing and refuses to generate until it does", async ({ + page, + }) => { + // The rule the whole surface exists for: nothing reaches the report that + // was not ticked. So a first run starts empty, Generate refuses the press + // and says why, and the one-click action is what makes that cost nothing — + // it fills the standard set and the control comes alive. + await openGesundheitsakte(page); + const generate = page.getByTestId("health-record-generate"); + await expect(generate).toBeVisible({ timeout: 10_000 }); + await expect(generate).toBeDisabled(); + await expect( + page.getByTestId("report-group-row-vitals-export"), + ).toContainText("0/7"); + await expect(page.getByTestId("report-scope-summary-export")).toContainText( + "Nothing selected yet", + ); + + await page.getByTestId("report-apply-standard-export").click(); + + await expect(generate).toBeEnabled(); + // Exactly the shipped set: the three classic vitals, weight and BMI, and + // nothing at all from the groups the template leaves alone. + await expect( + page.getByTestId("report-group-row-vitals-export"), + ).toContainText("3/7"); + await expect( + page.getByTestId("report-group-row-body-export"), + ).toContainText("2/12"); + await expect( + page.getByTestId("report-group-row-cardio-export"), + ).toContainText("0/14"); + // The fenced tier is not part of any one-click set. + await expect( + page.getByTestId("report-scope-summary-export"), + ).not.toContainText("incl."); + }); + + test("a share link cannot be minted on an empty scope either", async ({ + page, + }) => { + await openGesundheitsakte(page); + const create = page.getByTestId("share-create-submit"); + await expect(create).toBeVisible({ timeout: 10_000 }); + await page.locator("#share-label").fill("Cardiology"); + // A label alone is not a scope: the link would serve a page with nothing + // on it, so the control stays shut until something is chosen. + await expect(create).toBeDisabled(); + await expect(page.getByTestId("report-scope-summary-share")).toContainText( + "Nothing selected yet", + ); + await page.getByTestId("report-apply-standard-share").click(); + await expect(create).toBeEnabled(); + }); + + test("clears the tap floor on the group rows at 390 px", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openGesundheitsakte(page); + const row = page.getByTestId("report-group-row-vitals-export"); + await expect(row).toBeVisible({ timeout: 10_000 }); + const box = await row.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + }); + + test("opens a group's leaf grid from its chevron", async ({ page }) => { + await openGesundheitsakte(page); + const row = page.getByTestId("report-group-row-vitals-export"); + await expect(row).toBeVisible({ timeout: 10_000 }); + // Collapsed: the leaves are behind the disclosure, which is what makes a + // repeat run two interactions. + await expect(page.getByTestId("report-leaf-PULSE-export")).toHaveCount(0); + await row.getByRole("button", { expanded: false }).click(); + await expect(page.getByTestId("report-leaf-PULSE-export")).toBeVisible(); + }); + test("renders the import surfaces with stable testids", async ({ page }) => { await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); // v1.15.7 (issue #281) — the Apple Health and generic-JSON import @@ -89,11 +239,18 @@ test.describe("Settings → Export & Import", () => { expect(Array.isArray(parsed.moodEntries)).toBe(true); }); - test("included-data checklist is collapsed by default and expands on demand", async ({ + test("included-data disclosure opens on a first run and still collapses", async ({ page, }) => { - // v1.18.0 (S5) — the panel (and its disclosure) live on the - // dedicated Gesundheitsakte section now. + // The disclosure that fronts the scope picker on + // `/settings/gesundheitsakte`. An account with no saved selection is a + // first run, and a first run opens it: that is the only place the standard + // template is applied, and a template hidden behind a closed disclosure + // would be opt-out wearing a hat. + // + // Opening by default must not turn the disclosure into decoration, so the + // operable part is asserted in both directions — it closes, taking the + // panel with it, and it reopens. await page.route("**/api/share-links", async (route) => { if (route.request().method() !== "GET") { await route.continue(); @@ -105,29 +262,21 @@ test.describe("Settings → Export & Import", () => { body: JSON.stringify({ data: [], error: null, meta: null }), }); }); - const sharingDataReady = page.waitForResponse( - (response) => - response.status() === 200 && - response.request().method() === "GET" && - new URL(response.url()).pathname === "/api/share-links", - ); - await page.goto("/settings/gesundheitsakte", { - waitUntil: "domcontentloaded", - }); - await sharingDataReady; + await openGesundheitsakte(page); const toggle = page.getByTestId("health-record-included-data-toggle"); + const panel = page.getByTestId("health-record-included-data-panel"); await expect(toggle).toBeVisible({ timeout: 10_000 }); - // Collapsed on first render — the checklist panel is absent. + // Open on first render, with the checklist panel behind it. + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(panel).toBeVisible(); + // Collapses on demand, and the panel goes with it. + await toggle.click(); await expect(toggle).toHaveAttribute("aria-expanded", "false"); - await expect( - page.getByTestId("health-record-included-data-panel"), - ).toHaveCount(0); - // Expands on demand. + await expect(panel).toHaveCount(0); + // And back — the disclosure stays a disclosure. await toggle.click(); await expect(toggle).toHaveAttribute("aria-expanded", "true"); - await expect( - page.getByTestId("health-record-included-data-panel"), - ).toBeVisible(); + await expect(panel).toBeVisible(); }); test("Measurements CSV download fires a real download event", async ({ diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index 28d00a7d6..862f4a426 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -51,9 +51,10 @@ interface CreatedShare { } /** - * Drive the owner create flow: open the picker, isolate the share-fixture trio - * by search, select all three, create the link, and read the one-time token + - * passphrase off the reveal card. Also asserts the QR promotion. + * Drive the owner create flow: name the link, choose a record scope, open the + * picker, isolate the share-fixture trio by search, select all three, create + * the link, and read the one-time token + passphrase off the reveal card. Also + * asserts the QR promotion. */ async function createShareWithDocs(page: Page): Promise { const label = `${SHARE_DOC_PREFIX} ${Date.now()}`; @@ -67,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise { await page.goto("/settings/sharing"); await sharingDataReady; await page.locator("#share-label").fill(label); + // A record link starts with an empty scope and cannot be minted on one, so + // the scope is chosen here the way an owner chooses it: one click on the + // named standard set. + await page.getByTestId("report-apply-standard-share").click(); // Open the document picker and isolate the seeded trio. await page.getByTestId("share-attach-open").click(); diff --git a/messages/de.json b/messages/de.json index 0457825b4..4728f85d6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -53,11 +53,15 @@ }, "apiErrors": { "measurement": { - "create": { "invalid": "Prüfe die Messdaten und versuche es erneut." }, + "create": { + "invalid": "Prüfe die Messdaten und versuche es erneut." + }, "duplicate_timestamp": "Für diesen Zeitpunkt ist bereits eine Messung vorhanden." }, "mood": { - "list": { "invalid": "Die Filter der Stimmungsliste sind ungültig." }, + "list": { + "invalid": "Die Filter der Stimmungsliste sind ungültig." + }, "create": { "invalid": "Prüfe den Stimmungseintrag und versuche es erneut." }, @@ -71,8 +75,12 @@ } }, "labs": { - "create": { "invalid": "Prüfe den Laborwert und versuche es erneut." }, - "result": { "notFound": "Dieser Laborwert ist nicht mehr verfügbar." }, + "create": { + "invalid": "Prüfe den Laborwert und versuche es erneut." + }, + "result": { + "notFound": "Dieser Laborwert ist nicht mehr verfügbar." + }, "biomarker": { "notFound": "Dieser Biomarker ist nicht mehr verfügbar.", "duplicate": "Ein Biomarker mit diesem Namen ist bereits vorhanden.", @@ -102,7 +110,9 @@ "complete": { "invalid": "Prüfe die Profildaten und versuche es erneut." }, - "user": { "notFound": "Dein Konto wurde nicht gefunden." }, + "user": { + "notFound": "Dein Konto wurde nicht gefunden." + }, "step": { "rateLimited": "Zu viele Versuche. Warte kurz und versuche es erneut.", "invalid": "Dieser Onboarding-Schritt konnte nicht gespeichert werden.", @@ -160,16 +170,6 @@ "footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung", "footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.", "footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}", - "typeWeight": "Körpergewicht", - "typeBpSys": "Systolischer Blutdruck", - "typeBpDia": "Diastolischer Blutdruck", - "typePulse": "Ruhepuls", - "typeBodyFat": "Körperfettanteil", - "typeSleep": "Schlafdauer", - "typeSteps": "Aktivität (Schritte)", - "typeTotalBodyWater": "Gesamtkörperwasser", - "typeBoneMass": "Knochenmasse", - "typeOxygenSaturation": "Sauerstoffsättigung", "unitSteps": "Schritte", "bmiUnderweight": "Untergewicht", "bmiNormal": "Normalgewicht", @@ -205,8 +205,6 @@ "summaryTrend": "{label}: zuletzt {latest} {arrow}", "summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).", "chartsTitle": "Trend-Diagramme", - "aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert", - "aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.", "packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.", "wellnessTitle": "Wellness-Zusammenfassung", "wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.", @@ -4447,10 +4445,6 @@ "passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.", "noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.", "stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut." - }, - "cycle": { - "title": "Zyklus", - "description": "Zyklus- und Reproduktionsdaten als FHIR-Dokument — letzte Periode, durchschnittliche Zyklus- und Periodenlänge, aktuelle Phase." } }, "import": { @@ -5313,34 +5307,17 @@ "practiceName": "Praxis (optional)", "practiceNamePlaceholder": "z. B. Dr. med. Mustermann", "includedData": "Enthaltene Daten", - "groupVitals": "Vitalwerte", - "groupCardio": "Herz & Fitness", - "groupActivity": "Aktivität & Schlaf", - "weight": "Gewicht", - "bp": "Blutdruck", - "pulse": "Puls", - "oxygenSaturation": "Sauerstoffsättigung", - "bodyFat": "Körperfett", - "bodyComposition": "Körperzusammensetzung", - "restingHeartRate": "Ruhepuls", - "hrv": "Herzfrequenzvariabilität", - "vo2max": "VO₂max", - "steps": "Schritte", - "distance": "Distanz", - "sleep": "Schlaf", - "glucose": "Blutzucker", - "medications": "Medikamente & Therapietreue", - "mood": "Stimmung (standardmäßig aus — sensible Daten)", - "bmi": "BMI", "includeCharts": "Trend-Diagramme einbetten (nur PDF)", - "includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)", "fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).", "generate": "Erstellen", - "error": "Export fehlgeschlagen ({code}).", - "labs": "Laborwerte", - "allergies": "Allergien & Unverträglichkeiten", - "familyHistory": "Familienanamnese", - "documentsLink": "Dokumente zum Zeitraum" + "documentsLink": "Dokumente zum Zeitraum", + "range30": "Letzte 30 Tage", + "rangeCustom": "Eigener Zeitraum", + "rangeCustomStart": "Von", + "rangeCustomEnd": "Bis", + "errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.", + "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.", + "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet." }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", @@ -5354,9 +5331,6 @@ "expiry": "Läuft ab in (Tagen)", "expiryHint": "Höchstens {max} Tage.", "expiryInvalid": "Die Gültigkeit muss zwischen 1 und {max} Tagen liegen.", - "fhirApi": "Eingegrenzte FHIR-API erlauben", - "fhirApiHint": "Lasse den Link zusätzlich zur Seitenansicht einen schreibgeschützten FHIR-Endpunkt bereitstellen.", - "resourceTypes": "FHIR-Ressourcentypen", "create": "Link erstellen", "tokenCreated": "Freigabe-Link erstellt", "copied": "In die Zwischenablage kopiert.", @@ -5394,7 +5368,12 @@ "pickerEmpty": "Keine Dokumente gefunden.", "pickerDone": "Fertig", "pickerSelectedCount": "{count} von {max} ausgewählt", - "pickerMaxReached": "Maximum von {max} Dokumenten erreicht." + "pickerMaxReached": "Maximum von {max} Dokumenten erreicht.", + "scopeTitle": "Was dieser Link enthält", + "scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.", + "statusNeedsReselect": "Inhalt neu wählen", + "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.", + "reselectAction": "Ersatz anlegen" }, "genderOther": "Divers", "cycleTracking": { @@ -6930,7 +6909,6 @@ "period": "Berichtszeitraum: {start} – {end}", "expires": "Dieser Link läuft am {date} ab.", "provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.", - "vitals": "Vitalwerte", "labs": "Laborwerte", "medications": "Medikamente & Therapietreue", "bmi": "Body-Mass-Index (BMI)", @@ -6970,7 +6948,10 @@ "download": "Herunterladen", "attachmentHint": "Diese Datei wird auf Ihr Gerät heruntergeladen (keine Vorschau in der Seite).", "exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt." - } + }, + "downloadPdf": "Als PDF herunterladen", + "downloadFhir": "Als FHIR herunterladen", + "bmiSection": "Body-Mass-Index" }, "cycle": { "symptomCategory": { @@ -8877,5 +8858,42 @@ } } } + }, + "reportSelection": { + "groupIdentity": "Stammdaten", + "groupVitals": "Vitalwerte", + "groupCardio": "Herz & Kreislauf", + "groupBody": "Körpermaße", + "groupSleepRecovery": "Schlaf & Erholung", + "groupActivity": "Aktivität", + "groupMobility": "Mobilität & Gang", + "groupEnvironment": "Umgebung", + "groupGlucose": "Blutzucker", + "groupLabs": "Laborwerte", + "groupMedications": "Medikamente", + "groupHistory": "Vorerkrankungen & Allergien", + "groupSensitive": "Sensible Daten", + "leafPatientIdentity": "Name, Geburtsdatum, Geschlecht, Größe", + "leafInsurance": "Krankenkasse und Versichertennummer", + "leafGlucosePanel": "Blutzucker-Panel (Zeit im Zielbereich, GMI, Variabilität)", + "leafLabResults": "Laborwerte", + "leafMedicationList": "Medikamentenliste", + "leafMedicationAdministrations": "Jede erfasste Einnahme", + "leafMedicationCompliance": "Einnahmetreue", + "leafGlp1Therapy": "GLP-1-Therapie (Dosisverlauf, Nebenwirkungen)", + "leafAllergies": "Allergien und Unverträglichkeiten", + "leafIllnessEpisodes": "Krankheitsepisoden", + "leafFamilyHistory": "Familienanamnese", + "leafMood": "Stimmung", + "leafCycle": "Menstruationszyklus", + "templateStandard": "Standard-Arztbericht", + "applyStandard": "{template} übernehmen", + "standardHint": "Ein Klick füllt aus, was ein normaler Arztbesuch meist braucht. Jeder Eintrag bleibt änderbar.", + "groupCount": "{on}/{total}", + "groupDisclosure": "Einträge in {group} anzeigen", + "sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.", + "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", + "scopeCount": "{count} Einträge aus {groups} Bereichen", + "scopeSensitive": "inkl. {names}" } } diff --git a/messages/en.json b/messages/en.json index 37e2e2186..4191253be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -53,13 +53,21 @@ }, "apiErrors": { "measurement": { - "create": { "invalid": "Check the measurement details and try again." }, + "create": { + "invalid": "Check the measurement details and try again." + }, "duplicate_timestamp": "A measurement already exists for this time." }, "mood": { - "list": { "invalid": "The mood list filters are invalid." }, - "create": { "invalid": "Check the mood entry and try again." }, - "update": { "invalid": "Check the mood entry and try again." }, + "list": { + "invalid": "The mood list filters are invalid." + }, + "create": { + "invalid": "Check the mood entry and try again." + }, + "update": { + "invalid": "Check the mood entry and try again." + }, "duplicate_timestamp": "A mood entry already exists for this time.", "not_found": "This mood entry is no longer available.", "ratedFactor": { @@ -67,12 +75,18 @@ } }, "labs": { - "create": { "invalid": "Check the lab result and try again." }, - "result": { "notFound": "This lab result is no longer available." }, + "create": { + "invalid": "Check the lab result and try again." + }, + "result": { + "notFound": "This lab result is no longer available." + }, "biomarker": { "notFound": "This biomarker is no longer available.", "duplicate": "A biomarker with this name already exists.", - "create": { "invalid": "Check the biomarker details and try again." }, + "create": { + "invalid": "Check the biomarker details and try again." + }, "update": { "invalid": "Check the biomarker details and try again.", "referenceRangeInvalid": "The lower reference value must not exceed the upper value." @@ -90,9 +104,15 @@ "disclaimer": { "invalid": "Please confirm the medical disclaimer again." }, - "tour": { "invalid": "The tour progress could not be saved." }, - "complete": { "invalid": "Check the profile details and try again." }, - "user": { "notFound": "Your account could not be found." }, + "tour": { + "invalid": "The tour progress could not be saved." + }, + "complete": { + "invalid": "Check the profile details and try again." + }, + "user": { + "notFound": "Your account could not be found." + }, "step": { "rateLimited": "Too many attempts. Please wait and try again.", "invalid": "This onboarding step could not be saved.", @@ -150,16 +170,6 @@ "footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support", "footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.", "footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}", - "typeWeight": "Body weight", - "typeBpSys": "Systolic blood pressure", - "typeBpDia": "Diastolic blood pressure", - "typePulse": "Resting pulse", - "typeBodyFat": "Body fat", - "typeSleep": "Sleep duration", - "typeSteps": "Activity (steps)", - "typeTotalBodyWater": "Total body water", - "typeBoneMass": "Bone mass", - "typeOxygenSaturation": "Oxygen saturation", "unitSteps": "steps", "bmiUnderweight": "Underweight", "bmiNormal": "Normal weight", @@ -195,8 +205,6 @@ "summaryTrend": "{label}: latest {latest} {arrow}", "summaryAdherence": "Medication adherence: {rate}% (weighted mean).", "chartsTitle": "Trend charts", - "aiSummaryTitle": "AI summary — not clinically validated", - "aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.", "packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.", "wellnessTitle": "Wellness summary", "wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.", @@ -4437,10 +4445,6 @@ "passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.", "noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.", "stepUpNeeded": "Confirm your second factor, then try again." - }, - "cycle": { - "title": "Cycle", - "description": "Cycle and reproductive-health data as a FHIR document — last period, average cycle and period length, current phase." } }, "import": { @@ -5303,34 +5307,17 @@ "practiceName": "Practice (optional)", "practiceNamePlaceholder": "e.g. Dr. Smith", "includedData": "Included data", - "groupVitals": "Vital signs", - "groupCardio": "Heart & fitness", - "groupActivity": "Activity & sleep", - "weight": "Weight", - "bp": "Blood pressure", - "pulse": "Pulse", - "oxygenSaturation": "Oxygen saturation", - "bodyFat": "Body fat", - "bodyComposition": "Body composition", - "restingHeartRate": "Resting heart rate", - "hrv": "Heart rate variability", - "vo2max": "VO₂max", - "steps": "Steps", - "distance": "Distance", - "sleep": "Sleep", - "glucose": "Blood glucose", - "medications": "Medications & adherence", - "mood": "Mood (off by default — sensitive data)", - "bmi": "BMI", "includeCharts": "Embed trend charts (PDF only)", - "includeAiSummary": "Append AI summary (not clinically validated)", "fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).", "generate": "Generate", - "error": "Export failed ({code}).", - "labs": "Lab results", - "allergies": "Allergies & intolerances", - "familyHistory": "Family history", - "documentsLink": "Documents for this period" + "documentsLink": "Documents for this period", + "range30": "Last 30 days", + "rangeCustom": "Custom period", + "rangeCustomStart": "From", + "rangeCustomEnd": "To", + "errorGeneric": "The export could not be generated. Please try again.", + "errorRateLimit": "You have reached the export limit for this hour.", + "errorModuleDisabled": "The health record export is switched off for this account." }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", @@ -5344,9 +5331,6 @@ "expiry": "Expires in (days)", "expiryHint": "At most {max} days.", "expiryInvalid": "Expiry must be between 1 and {max} days.", - "fhirApi": "Allow scoped FHIR API", - "fhirApiHint": "Let the link serve a read-only FHIR endpoint in addition to the page view.", - "resourceTypes": "FHIR resource types", "create": "Create link", "tokenCreated": "Share link created", "copied": "Copied to clipboard.", @@ -5384,7 +5368,12 @@ "pickerEmpty": "No documents found.", "pickerDone": "Done", "pickerSelectedCount": "{count} of {max} selected", - "pickerMaxReached": "You've reached the maximum of {max} documents." + "pickerMaxReached": "You've reached the maximum of {max} documents.", + "scopeTitle": "What this link contains", + "scopeInsuranceExcluded": "Insurance details are never shared through a link.", + "statusNeedsReselect": "Pick contents again", + "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.", + "reselectAction": "Create a replacement" }, "genderOther": "Other", "cycleTracking": { @@ -6920,7 +6909,6 @@ "period": "Reporting period: {start} – {end}", "expires": "This link expires on {date}.", "provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.", - "vitals": "Vital signs", "labs": "Lab values", "medications": "Medications & adherence", "bmi": "Body mass index (BMI)", @@ -6960,7 +6948,10 @@ "download": "Download", "attachmentHint": "This file downloads to your device (no in-page preview).", "exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images." - } + }, + "downloadPdf": "Download as PDF", + "downloadFhir": "Download as FHIR", + "bmiSection": "Body mass index" }, "cycle": { "symptomCategory": { @@ -8867,5 +8858,42 @@ } } } + }, + "reportSelection": { + "groupIdentity": "Personal details", + "groupVitals": "Vital signs", + "groupCardio": "Heart & circulation", + "groupBody": "Body measurements", + "groupSleepRecovery": "Sleep & recovery", + "groupActivity": "Activity", + "groupMobility": "Mobility & gait", + "groupEnvironment": "Environment", + "groupGlucose": "Blood glucose", + "groupLabs": "Lab results", + "groupMedications": "Medications", + "groupHistory": "Conditions & allergies", + "groupSensitive": "Sensitive data", + "leafPatientIdentity": "Name, date of birth, gender, height", + "leafInsurance": "Insurer and insurance number", + "leafGlucosePanel": "Glucose panel (time in range, GMI, variability)", + "leafLabResults": "Lab results", + "leafMedicationList": "Medication list", + "leafMedicationAdministrations": "Every logged dose", + "leafMedicationCompliance": "Adherence rate", + "leafGlp1Therapy": "GLP-1 therapy (dose history, side effects)", + "leafAllergies": "Allergies and intolerances", + "leafIllnessEpisodes": "Illness episodes", + "leafFamilyHistory": "Family history", + "leafMood": "Mood", + "leafCycle": "Menstrual cycle", + "templateStandard": "Standard doctor's report", + "applyStandard": "Apply {template}", + "standardHint": "One click fills in what a general visit usually needs. Every entry stays editable.", + "groupCount": "{on}/{total}", + "groupDisclosure": "Show the entries in {group}", + "sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.", + "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.", + "scopeCount": "{count} entries from {groups} areas", + "scopeSensitive": "incl. {names}" } } diff --git a/messages/es.json b/messages/es.json index 5430a002a..d046fbd97 100644 --- a/messages/es.json +++ b/messages/es.json @@ -59,7 +59,9 @@ "duplicate_timestamp": "Ya existe una medición para esta hora." }, "mood": { - "list": { "invalid": "Los filtros de la lista de ánimo no son válidos." }, + "list": { + "invalid": "Los filtros de la lista de ánimo no son válidos." + }, "create": { "invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo." }, @@ -99,12 +101,18 @@ } }, "onboarding": { - "disclaimer": { "invalid": "Confirma de nuevo el aviso médico." }, - "tour": { "invalid": "No se pudo guardar el progreso del recorrido." }, + "disclaimer": { + "invalid": "Confirma de nuevo el aviso médico." + }, + "tour": { + "invalid": "No se pudo guardar el progreso del recorrido." + }, "complete": { "invalid": "Comprueba los datos del perfil e inténtalo de nuevo." }, - "user": { "notFound": "No se pudo encontrar tu cuenta." }, + "user": { + "notFound": "No se pudo encontrar tu cuenta." + }, "step": { "rateLimited": "Demasiados intentos. Espera e inténtalo de nuevo.", "invalid": "No se pudo guardar este paso de la introducción.", @@ -162,16 +170,6 @@ "footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar", "footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.", "footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}", - "typeWeight": "Peso corporal", - "typeBpSys": "Tensión arterial sistólica", - "typeBpDia": "Tensión arterial diastólica", - "typePulse": "Pulso en reposo", - "typeBodyFat": "Grasa corporal", - "typeSleep": "Duración del sueño", - "typeSteps": "Actividad (pasos)", - "typeTotalBodyWater": "Agua corporal total", - "typeBoneMass": "Masa ósea", - "typeOxygenSaturation": "Saturación de oxígeno", "unitSteps": "Pasos", "bmiUnderweight": "Peso insuficiente", "bmiNormal": "Peso normal", @@ -207,8 +205,6 @@ "summaryTrend": "{label}: último {latest} {arrow}", "summaryAdherence": "Adherencia a la medicación: {rate}% (media ponderada).", "chartsTitle": "Gráficos de tendencia", - "aiSummaryTitle": "Resumen de IA — no validado clínicamente", - "aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.", "packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.", "wellnessTitle": "Resumen de bienestar", "wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.", @@ -4449,10 +4445,6 @@ "passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.", "noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.", "stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo." - }, - "cycle": { - "title": "Ciclo", - "description": "Datos del ciclo y de salud reproductiva como documento FHIR: última menstruación, duración media del ciclo y del periodo, fase actual." } }, "import": { @@ -5315,34 +5307,17 @@ "practiceName": "Consulta (opcional)", "practiceNamePlaceholder": "p. ej. Dr. García", "includedData": "Datos incluidos", - "groupVitals": "Signos vitales", - "groupCardio": "Corazón y forma física", - "groupActivity": "Actividad y sueño", - "weight": "Peso", - "bp": "Presión arterial", - "pulse": "Pulso", - "oxygenSaturation": "Saturación de oxígeno", - "bodyFat": "Grasa corporal", - "bodyComposition": "Composición corporal", - "restingHeartRate": "Pulso en reposo", - "hrv": "Variabilidad de la frecuencia cardíaca", - "vo2max": "VO₂máx", - "steps": "Pasos", - "distance": "Distancia", - "sleep": "Sueño", - "glucose": "Glucosa en sangre", - "medications": "Medicamentos y adherencia", - "mood": "Estado de ánimo (desactivado por defecto — datos sensibles)", - "bmi": "IMC", "includeCharts": "Incrustar gráficos de tendencia (solo PDF)", - "includeAiSummary": "Añadir resumen de IA (no validado clínicamente)", "fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).", "generate": "Generar", - "error": "Error en la exportación ({code}).", - "labs": "Resultados de análisis", - "allergies": "Alergias e intolerancias", - "familyHistory": "Antecedentes familiares", - "documentsLink": "Documentos del período" + "documentsLink": "Documentos del período", + "range30": "Últimos 30 días", + "rangeCustom": "Periodo personalizado", + "rangeCustomStart": "Desde", + "rangeCustomEnd": "Hasta", + "errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.", + "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.", + "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta." }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -5356,9 +5331,6 @@ "expiry": "Caduca en (días)", "expiryHint": "Como máximo {max} días.", "expiryInvalid": "La caducidad debe estar entre 1 y {max} días.", - "fhirApi": "Permitir API FHIR acotada", - "fhirApiHint": "Deja que el enlace sirva un endpoint FHIR de solo lectura además de la vista de página.", - "resourceTypes": "Tipos de recurso FHIR", "create": "Crear enlace", "tokenCreated": "Enlace para compartir creado", "copied": "Copiado al portapapeles.", @@ -5396,7 +5368,12 @@ "pickerEmpty": "No se encontraron documentos.", "pickerDone": "Listo", "pickerSelectedCount": "{count} de {max} seleccionados", - "pickerMaxReached": "Has alcanzado el máximo de {max} documentos." + "pickerMaxReached": "Has alcanzado el máximo de {max} documentos.", + "scopeTitle": "Qué contiene este enlace", + "scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.", + "statusNeedsReselect": "Elegir contenido de nuevo", + "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.", + "reselectAction": "Crear un reemplazo" }, "genderOther": "Otro", "cycleTracking": { @@ -6932,7 +6909,6 @@ "period": "Período del informe: {start} – {end}", "expires": "Este enlace caduca el {date}.", "provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.", - "vitals": "Signos vitales", "labs": "Valores de laboratorio", "medications": "Medicamentos y adherencia", "bmi": "Índice de masa corporal (IMC)", @@ -6972,7 +6948,10 @@ "download": "Descargar", "attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).", "exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas." - } + }, + "downloadPdf": "Descargar como PDF", + "downloadFhir": "Descargar como FHIR", + "bmiSection": "Índice de masa corporal" }, "cycle": { "symptomCategory": { @@ -8879,5 +8858,42 @@ } } } + }, + "reportSelection": { + "groupIdentity": "Datos personales", + "groupVitals": "Signos vitales", + "groupCardio": "Corazón y circulación", + "groupBody": "Medidas corporales", + "groupSleepRecovery": "Sueño y recuperación", + "groupActivity": "Actividad", + "groupMobility": "Movilidad y marcha", + "groupEnvironment": "Entorno", + "groupGlucose": "Glucosa en sangre", + "groupLabs": "Resultados de laboratorio", + "groupMedications": "Medicamentos", + "groupHistory": "Antecedentes y alergias", + "groupSensitive": "Datos sensibles", + "leafPatientIdentity": "Nombre, fecha de nacimiento, sexo, altura", + "leafInsurance": "Aseguradora y número de seguro", + "leafGlucosePanel": "Panel de glucosa (tiempo en rango, GMI, variabilidad)", + "leafLabResults": "Resultados de laboratorio", + "leafMedicationList": "Lista de medicamentos", + "leafMedicationAdministrations": "Cada dosis registrada", + "leafMedicationCompliance": "Adherencia", + "leafGlp1Therapy": "Terapia GLP-1 (historial de dosis, efectos secundarios)", + "leafAllergies": "Alergias e intolerancias", + "leafIllnessEpisodes": "Episodios de enfermedad", + "leafFamilyHistory": "Antecedentes familiares", + "leafMood": "Estado de ánimo", + "leafCycle": "Ciclo menstrual", + "templateStandard": "Informe médico estándar", + "applyStandard": "Aplicar {template}", + "standardHint": "Un clic rellena lo que suele necesitar una consulta general. Cada entrada sigue siendo editable.", + "groupCount": "{on}/{total}", + "groupDisclosure": "Mostrar las entradas de {group}", + "sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.", + "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", + "scopeCount": "{count} entradas de {groups} áreas", + "scopeSensitive": "incl. {names}" } } diff --git a/messages/fr.json b/messages/fr.json index 0bc0a7a47..2eda57ea9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -62,8 +62,12 @@ "list": { "invalid": "Les filtres de la liste d’humeur ne sont pas valides." }, - "create": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." }, - "update": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." }, + "create": { + "invalid": "Vérifiez l’entrée d’humeur et réessayez." + }, + "update": { + "invalid": "Vérifiez l’entrée d’humeur et réessayez." + }, "duplicate_timestamp": "Une entrée d’humeur existe déjà pour cette heure.", "not_found": "Cette entrée d’humeur n’est plus disponible.", "ratedFactor": { @@ -106,7 +110,9 @@ "complete": { "invalid": "Vérifiez les informations du profil et réessayez." }, - "user": { "notFound": "Votre compte est introuvable." }, + "user": { + "notFound": "Votre compte est introuvable." + }, "step": { "rateLimited": "Trop de tentatives. Patientez puis réessayez.", "invalid": "Cette étape d’accueil n’a pas pu être enregistrée.", @@ -164,16 +170,6 @@ "footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir", "footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.", "footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}", - "typeWeight": "Poids corporel", - "typeBpSys": "Tension artérielle systolique", - "typeBpDia": "Tension artérielle diastolique", - "typePulse": "Pouls au repos", - "typeBodyFat": "Masse grasse", - "typeSleep": "Durée du sommeil", - "typeSteps": "Activité (pas)", - "typeTotalBodyWater": "Eau corporelle totale", - "typeBoneMass": "Masse osseuse", - "typeOxygenSaturation": "Saturation en oxygène", "unitSteps": "Pas", "bmiUnderweight": "Insuffisance pondérale", "bmiNormal": "Poids normal", @@ -209,8 +205,6 @@ "summaryTrend": "{label} : dernier {latest} {arrow}", "summaryAdherence": "Observance médicamenteuse : {rate}% (moyenne pondérée).", "chartsTitle": "Graphiques de tendance", - "aiSummaryTitle": "Résumé IA — non validé cliniquement", - "aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.", "packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.", "wellnessTitle": "Résumé de bien-être", "wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.", @@ -4451,10 +4445,6 @@ "passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.", "noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.", "stepUpNeeded": "Confirmez votre second facteur, puis réessayez." - }, - "cycle": { - "title": "Cycle", - "description": "Données de cycle et de santé reproductive sous forme de document FHIR : dernières règles, durée moyenne du cycle et des règles, phase actuelle." } }, "import": { @@ -5317,34 +5307,17 @@ "practiceName": "Cabinet (facultatif)", "practiceNamePlaceholder": "p. ex. Dr Dupont", "includedData": "Données incluses", - "groupVitals": "Signes vitaux", - "groupCardio": "Cœur & forme", - "groupActivity": "Activité & sommeil", - "weight": "Poids", - "bp": "Tension artérielle", - "pulse": "Pouls", - "oxygenSaturation": "Saturation en oxygène", - "bodyFat": "Masse grasse", - "bodyComposition": "Composition corporelle", - "restingHeartRate": "Pouls au repos", - "hrv": "Variabilité de la fréquence cardiaque", - "vo2max": "VO₂max", - "steps": "Pas", - "distance": "Distance", - "sleep": "Sommeil", - "glucose": "Glycémie", - "medications": "Médicaments & observance", - "mood": "Humeur (désactivé par défaut — données sensibles)", - "bmi": "IMC", "includeCharts": "Intégrer les graphiques de tendance (PDF uniquement)", - "includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)", "fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).", "generate": "Générer", - "error": "Échec de l’export ({code}).", - "labs": "Résultats d'analyses", - "allergies": "Allergies et intolérances", - "familyHistory": "Antécédents familiaux", - "documentsLink": "Documents de la période" + "documentsLink": "Documents de la période", + "range30": "30 derniers jours", + "rangeCustom": "Période personnalisée", + "rangeCustomStart": "Du", + "rangeCustomEnd": "Au", + "errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.", + "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.", + "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte." }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", @@ -5358,9 +5331,6 @@ "expiry": "Expire dans (jours)", "expiryHint": "Au plus {max} jours.", "expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.", - "fhirApi": "Autoriser l'API FHIR restreinte", - "fhirApiHint": "Laissez le lien servir un point d'accès FHIR en lecture seule en plus de la vue page.", - "resourceTypes": "Types de ressource FHIR", "create": "Créer le lien", "tokenCreated": "Lien de partage créé", "copied": "Copié dans le presse-papiers.", @@ -5398,7 +5368,12 @@ "pickerEmpty": "Aucun document trouvé.", "pickerDone": "Terminé", "pickerSelectedCount": "{count} sur {max} sélectionnés", - "pickerMaxReached": "Vous avez atteint le maximum de {max} documents." + "pickerMaxReached": "Vous avez atteint le maximum de {max} documents.", + "scopeTitle": "Ce que contient ce lien", + "scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.", + "statusNeedsReselect": "Choisir à nouveau le contenu", + "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.", + "reselectAction": "Créer un remplacement" }, "genderOther": "Autre", "cycleTracking": { @@ -6934,7 +6909,6 @@ "period": "Période couverte : {start} – {end}", "expires": "Ce lien expire le {date}.", "provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.", - "vitals": "Signes vitaux", "labs": "Valeurs de laboratoire", "medications": "Médicaments et observance", "bmi": "Indice de masse corporelle (IMC)", @@ -6974,7 +6948,10 @@ "download": "Télécharger", "attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).", "exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées." - } + }, + "downloadPdf": "Télécharger en PDF", + "downloadFhir": "Télécharger en FHIR", + "bmiSection": "Indice de masse corporelle" }, "cycle": { "symptomCategory": { @@ -8881,5 +8858,42 @@ } } } + }, + "reportSelection": { + "groupIdentity": "Données personnelles", + "groupVitals": "Signes vitaux", + "groupCardio": "Cœur et circulation", + "groupBody": "Mensurations", + "groupSleepRecovery": "Sommeil et récupération", + "groupActivity": "Activité", + "groupMobility": "Mobilité et marche", + "groupEnvironment": "Environnement", + "groupGlucose": "Glycémie", + "groupLabs": "Résultats de laboratoire", + "groupMedications": "Médicaments", + "groupHistory": "Antécédents et allergies", + "groupSensitive": "Données sensibles", + "leafPatientIdentity": "Nom, date de naissance, sexe, taille", + "leafInsurance": "Assurance et numéro d'assuré", + "leafGlucosePanel": "Bilan glycémique (temps dans la cible, GMI, variabilité)", + "leafLabResults": "Résultats de laboratoire", + "leafMedicationList": "Liste des médicaments", + "leafMedicationAdministrations": "Chaque prise enregistrée", + "leafMedicationCompliance": "Observance", + "leafGlp1Therapy": "Traitement GLP-1 (historique des doses, effets indésirables)", + "leafAllergies": "Allergies et intolérances", + "leafIllnessEpisodes": "Épisodes de maladie", + "leafFamilyHistory": "Antécédents familiaux", + "leafMood": "Humeur", + "leafCycle": "Cycle menstruel", + "templateStandard": "Compte rendu médical standard", + "applyStandard": "Appliquer {template}", + "standardHint": "Un clic remplit ce dont une consultation générale a généralement besoin. Chaque élément reste modifiable.", + "groupCount": "{on}/{total}", + "groupDisclosure": "Afficher les éléments de {group}", + "sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.", + "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.", + "scopeCount": "{count} éléments issus de {groups} domaines", + "scopeSensitive": "dont {names}" } } diff --git a/messages/it.json b/messages/it.json index 8064f1679..f772e54ed 100644 --- a/messages/it.json +++ b/messages/it.json @@ -53,13 +53,21 @@ }, "apiErrors": { "measurement": { - "create": { "invalid": "Controlla i dati della misurazione e riprova." }, + "create": { + "invalid": "Controlla i dati della misurazione e riprova." + }, "duplicate_timestamp": "Esiste già una misurazione per questo orario." }, "mood": { - "list": { "invalid": "I filtri dell’elenco dell’umore non sono validi." }, - "create": { "invalid": "Controlla la voce dell’umore e riprova." }, - "update": { "invalid": "Controlla la voce dell’umore e riprova." }, + "list": { + "invalid": "I filtri dell’elenco dell’umore non sono validi." + }, + "create": { + "invalid": "Controlla la voce dell’umore e riprova." + }, + "update": { + "invalid": "Controlla la voce dell’umore e riprova." + }, "duplicate_timestamp": "Esiste già una voce dell’umore per questo orario.", "not_found": "Questa voce dell’umore non è più disponibile.", "ratedFactor": { @@ -76,7 +84,9 @@ "biomarker": { "notFound": "Questo biomarcatore non è più disponibile.", "duplicate": "Esiste già un biomarcatore con questo nome.", - "create": { "invalid": "Controlla i dati del biomarcatore e riprova." }, + "create": { + "invalid": "Controlla i dati del biomarcatore e riprova." + }, "update": { "invalid": "Controlla i dati del biomarcatore e riprova.", "referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore." @@ -91,12 +101,18 @@ } }, "onboarding": { - "disclaimer": { "invalid": "Conferma nuovamente l’avviso medico." }, + "disclaimer": { + "invalid": "Conferma nuovamente l’avviso medico." + }, "tour": { "invalid": "Non è stato possibile salvare l’avanzamento del tour." }, - "complete": { "invalid": "Controlla i dati del profilo e riprova." }, - "user": { "notFound": "Impossibile trovare il tuo account." }, + "complete": { + "invalid": "Controlla i dati del profilo e riprova." + }, + "user": { + "notFound": "Impossibile trovare il tuo account." + }, "step": { "rateLimited": "Troppi tentativi. Attendi e riprova.", "invalid": "Non è stato possibile salvare questo passaggio introduttivo.", @@ -154,16 +170,6 @@ "footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare", "footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.", "footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}", - "typeWeight": "Peso corporeo", - "typeBpSys": "Pressione arteriosa sistolica", - "typeBpDia": "Pressione arteriosa diastolica", - "typePulse": "Pulsazioni a riposo", - "typeBodyFat": "Massa grassa", - "typeSleep": "Durata del sonno", - "typeSteps": "Attività (passi)", - "typeTotalBodyWater": "Acqua corporea totale", - "typeBoneMass": "Massa ossea", - "typeOxygenSaturation": "Saturazione di ossigeno", "unitSteps": "Passi", "bmiUnderweight": "Sottopeso", "bmiNormal": "Peso normale", @@ -199,8 +205,6 @@ "summaryTrend": "{label}: ultimo {latest} {arrow}", "summaryAdherence": "Aderenza ai farmaci: {rate}% (media ponderata).", "chartsTitle": "Grafici di tendenza", - "aiSummaryTitle": "Riepilogo IA — non validato clinicamente", - "aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.", "packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.", "wellnessTitle": "Riepilogo del benessere", "wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.", @@ -4441,10 +4445,6 @@ "passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.", "noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.", "stepUpNeeded": "Conferma il secondo fattore e riprova." - }, - "cycle": { - "title": "Ciclo", - "description": "Dati del ciclo e di salute riproduttiva come documento FHIR: ultima mestruazione, durata media di ciclo e mestruazioni, fase attuale." } }, "import": { @@ -5307,34 +5307,17 @@ "practiceName": "Studio (facoltativo)", "practiceNamePlaceholder": "es. Dr. Rossi", "includedData": "Dati inclusi", - "groupVitals": "Parametri vitali", - "groupCardio": "Cuore & fitness", - "groupActivity": "Attività & sonno", - "weight": "Peso", - "bp": "Pressione sanguigna", - "pulse": "Polso", - "oxygenSaturation": "Saturazione di ossigeno", - "bodyFat": "Massa grassa", - "bodyComposition": "Composizione corporea", - "restingHeartRate": "Frequenza a riposo", - "hrv": "Variabilità della frequenza cardiaca", - "vo2max": "VO₂max", - "steps": "Passi", - "distance": "Distanza", - "sleep": "Sonno", - "glucose": "Glicemia", - "medications": "Farmaci e aderenza", - "mood": "Umore (disattivato di default — dati sensibili)", - "bmi": "BMI", "includeCharts": "Incorpora grafici di tendenza (solo PDF)", - "includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)", "fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).", "generate": "Genera", - "error": "Esportazione non riuscita ({code}).", - "labs": "Risultati degli esami", - "allergies": "Allergie e intolleranze", - "familyHistory": "Anamnesi familiare", - "documentsLink": "Documenti del periodo" + "documentsLink": "Documenti del periodo", + "range30": "Ultimi 30 giorni", + "rangeCustom": "Periodo personalizzato", + "rangeCustomStart": "Dal", + "rangeCustomEnd": "Al", + "errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.", + "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.", + "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account." }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", @@ -5348,9 +5331,6 @@ "expiry": "Scade tra (giorni)", "expiryHint": "Al massimo {max} giorni.", "expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.", - "fhirApi": "Consenti API FHIR limitata", - "fhirApiHint": "Lascia che il link offra un endpoint FHIR di sola lettura oltre alla vista della pagina.", - "resourceTypes": "Tipi di risorsa FHIR", "create": "Crea link", "tokenCreated": "Link di condivisione creato", "copied": "Copiato negli appunti.", @@ -5388,7 +5368,12 @@ "pickerEmpty": "Nessun documento trovato.", "pickerDone": "Fatto", "pickerSelectedCount": "{count} di {max} selezionati", - "pickerMaxReached": "Hai raggiunto il massimo di {max} documenti." + "pickerMaxReached": "Hai raggiunto il massimo di {max} documenti.", + "scopeTitle": "Cosa contiene questo link", + "scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.", + "statusNeedsReselect": "Scegli di nuovo il contenuto", + "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.", + "reselectAction": "Crea un sostituto" }, "genderOther": "Altro", "cycleTracking": { @@ -6924,7 +6909,6 @@ "period": "Periodo del report: {start} – {end}", "expires": "Questo link scade il {date}.", "provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.", - "vitals": "Parametri vitali", "labs": "Valori di laboratorio", "medications": "Farmaci e aderenza", "bmi": "Indice di massa corporea (IMC)", @@ -6964,7 +6948,10 @@ "download": "Scarica", "attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).", "exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise." - } + }, + "downloadPdf": "Scarica come PDF", + "downloadFhir": "Scarica come FHIR", + "bmiSection": "Indice di massa corporea" }, "cycle": { "symptomCategory": { @@ -8871,5 +8858,42 @@ } } } + }, + "reportSelection": { + "groupIdentity": "Dati anagrafici", + "groupVitals": "Parametri vitali", + "groupCardio": "Cuore e circolazione", + "groupBody": "Misure corporee", + "groupSleepRecovery": "Sonno e recupero", + "groupActivity": "Attività", + "groupMobility": "Mobilità e andatura", + "groupEnvironment": "Ambiente", + "groupGlucose": "Glicemia", + "groupLabs": "Esami di laboratorio", + "groupMedications": "Farmaci", + "groupHistory": "Patologie e allergie", + "groupSensitive": "Dati sensibili", + "leafPatientIdentity": "Nome, data di nascita, sesso, altezza", + "leafInsurance": "Assicurazione e numero di iscrizione", + "leafGlucosePanel": "Pannello glicemico (tempo nel range, GMI, variabilità)", + "leafLabResults": "Esami di laboratorio", + "leafMedicationList": "Elenco dei farmaci", + "leafMedicationAdministrations": "Ogni dose registrata", + "leafMedicationCompliance": "Aderenza", + "leafGlp1Therapy": "Terapia GLP-1 (storico dosi, effetti collaterali)", + "leafAllergies": "Allergie e intolleranze", + "leafIllnessEpisodes": "Episodi di malattia", + "leafFamilyHistory": "Anamnesi familiare", + "leafMood": "Umore", + "leafCycle": "Ciclo mestruale", + "templateStandard": "Referto medico standard", + "applyStandard": "Applica {template}", + "standardHint": "Un clic compila ciò che serve di solito a una visita generale. Ogni voce resta modificabile.", + "groupCount": "{on}/{total}", + "groupDisclosure": "Mostra le voci di {group}", + "sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.", + "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.", + "scopeCount": "{count} voci da {groups} aree", + "scopeSensitive": "incl. {names}" } } diff --git a/messages/pl.json b/messages/pl.json index 9d18cea87..45354f0dc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -53,13 +53,21 @@ }, "apiErrors": { "measurement": { - "create": { "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." }, + "create": { + "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." + }, "duplicate_timestamp": "Pomiar dla tego czasu już istnieje." }, "mood": { - "list": { "invalid": "Filtry listy nastroju są nieprawidłowe." }, - "create": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." }, - "update": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." }, + "list": { + "invalid": "Filtry listy nastroju są nieprawidłowe." + }, + "create": { + "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." + }, + "update": { + "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." + }, "duplicate_timestamp": "Wpis nastroju dla tego czasu już istnieje.", "not_found": "Ten wpis nastroju nie jest już dostępny.", "ratedFactor": { @@ -67,12 +75,18 @@ } }, "labs": { - "create": { "invalid": "Sprawdź wynik badania i spróbuj ponownie." }, - "result": { "notFound": "Ten wynik badania nie jest już dostępny." }, + "create": { + "invalid": "Sprawdź wynik badania i spróbuj ponownie." + }, + "result": { + "notFound": "Ten wynik badania nie jest już dostępny." + }, "biomarker": { "notFound": "Ten biomarker nie jest już dostępny.", "duplicate": "Biomarker o tej nazwie już istnieje.", - "create": { "invalid": "Sprawdź dane biomarkera i spróbuj ponownie." }, + "create": { + "invalid": "Sprawdź dane biomarkera i spróbuj ponownie." + }, "update": { "invalid": "Sprawdź dane biomarkera i spróbuj ponownie.", "referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej." @@ -87,10 +101,18 @@ } }, "onboarding": { - "disclaimer": { "invalid": "Potwierdź ponownie informację medyczną." }, - "tour": { "invalid": "Nie udało się zapisać postępu prezentacji." }, - "complete": { "invalid": "Sprawdź dane profilu i spróbuj ponownie." }, - "user": { "notFound": "Nie znaleziono Twojego konta." }, + "disclaimer": { + "invalid": "Potwierdź ponownie informację medyczną." + }, + "tour": { + "invalid": "Nie udało się zapisać postępu prezentacji." + }, + "complete": { + "invalid": "Sprawdź dane profilu i spróbuj ponownie." + }, + "user": { + "notFound": "Nie znaleziono Twojego konta." + }, "step": { "rateLimited": "Zbyt wiele prób. Zaczekaj i spróbuj ponownie.", "invalid": "Nie udało się zapisać tego kroku wprowadzenia.", @@ -148,16 +170,6 @@ "footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać", "footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.", "footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}", - "typeWeight": "Masa ciała", - "typeBpSys": "Ciśnienie skurczowe", - "typeBpDia": "Ciśnienie rozkurczowe", - "typePulse": "Tętno spoczynkowe", - "typeBodyFat": "Tkanka tłuszczowa", - "typeSleep": "Czas snu", - "typeSteps": "Aktywność (kroki)", - "typeTotalBodyWater": "Całkowita woda w organizmie", - "typeBoneMass": "Masa kostna", - "typeOxygenSaturation": "Saturacja tlenem", "unitSteps": "Kroki", "bmiUnderweight": "Niedowaga", "bmiNormal": "Waga prawidłowa", @@ -193,8 +205,6 @@ "summaryTrend": "{label}: ostatnio {latest} {arrow}", "summaryAdherence": "Przestrzeganie zaleceń lekowych: {rate}% (średnia ważona).", "chartsTitle": "Wykresy trendów", - "aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie", - "aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.", "packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.", "wellnessTitle": "Podsumowanie dobrostanu", "wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.", @@ -4435,10 +4445,6 @@ "passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.", "noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.", "stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie." - }, - "cycle": { - "title": "Cykl", - "description": "Dane cyklu i zdrowia reprodukcyjnego jako dokument FHIR: ostatnia miesiączka, średnia długość cyklu i miesiączki, bieżąca faza." } }, "import": { @@ -5301,34 +5307,17 @@ "practiceName": "Gabinet (opcjonalnie)", "practiceNamePlaceholder": "np. dr Kowalski", "includedData": "Uwzględnione dane", - "groupVitals": "Parametry życiowe", - "groupCardio": "Serce i kondycja", - "groupActivity": "Aktywność i sen", - "weight": "Waga", - "bp": "Ciśnienie krwi", - "pulse": "Tętno", - "oxygenSaturation": "Saturacja tlenem", - "bodyFat": "Tkanka tłuszczowa", - "bodyComposition": "Skład ciała", - "restingHeartRate": "Tętno spoczynkowe", - "hrv": "Zmienność rytmu serca", - "vo2max": "VO₂max", - "steps": "Kroki", - "distance": "Dystans", - "sleep": "Sen", - "glucose": "Glukoza we krwi", - "medications": "Leki i przestrzeganie zaleceń", - "mood": "Nastrój (domyślnie wyłączony — dane wrażliwe)", - "bmi": "BMI", "includeCharts": "Osadź wykresy trendów (tylko PDF)", - "includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)", "fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).", "generate": "Generuj", - "error": "Eksport nie powiódł się ({code}).", - "labs": "Wyniki badań", - "allergies": "Alergie i nietolerancje", - "familyHistory": "Wywiad rodzinny", - "documentsLink": "Dokumenty z tego okresu" + "documentsLink": "Dokumenty z tego okresu", + "range30": "Ostatnie 30 dni", + "rangeCustom": "Własny okres", + "rangeCustomStart": "Od", + "rangeCustomEnd": "Do", + "errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.", + "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.", + "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta." }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", @@ -5342,9 +5331,6 @@ "expiry": "Wygasa za (dni)", "expiryHint": "Najwyżej {max} dni.", "expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.", - "fhirApi": "Zezwól na ograniczone API FHIR", - "fhirApiHint": "Pozwól, aby link udostępniał punkt końcowy FHIR tylko do odczytu obok widoku strony.", - "resourceTypes": "Typy zasobów FHIR", "create": "Utwórz link", "tokenCreated": "Link udostępniania utworzony", "copied": "Skopiowano do schowka.", @@ -5382,7 +5368,12 @@ "pickerEmpty": "Nie znaleziono dokumentów.", "pickerDone": "Gotowe", "pickerSelectedCount": "Wybrano {count} z {max}", - "pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów." + "pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów.", + "scopeTitle": "Co zawiera ten link", + "scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.", + "statusNeedsReselect": "Wybierz zawartość ponownie", + "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.", + "reselectAction": "Utwórz zamiennik" }, "genderOther": "Inna", "cycleTracking": { @@ -6918,7 +6909,6 @@ "period": "Okres raportu: {start} – {end}", "expires": "Ten link wygasa dnia {date}.", "provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.", - "vitals": "Parametry życiowe", "labs": "Wyniki laboratoryjne", "medications": "Leki i przestrzeganie zaleceń", "bmi": "Wskaźnik masy ciała (BMI)", @@ -6958,7 +6948,10 @@ "download": "Pobierz", "attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).", "exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP." - } + }, + "downloadPdf": "Pobierz jako PDF", + "downloadFhir": "Pobierz jako FHIR", + "bmiSection": "Wskaźnik masy ciała" }, "cycle": { "symptomCategory": { @@ -8865,5 +8858,42 @@ } } } + }, + "reportSelection": { + "groupIdentity": "Dane osobowe", + "groupVitals": "Parametry życiowe", + "groupCardio": "Serce i krążenie", + "groupBody": "Wymiary ciała", + "groupSleepRecovery": "Sen i regeneracja", + "groupActivity": "Aktywność", + "groupMobility": "Mobilność i chód", + "groupEnvironment": "Otoczenie", + "groupGlucose": "Poziom glukozy", + "groupLabs": "Wyniki badań", + "groupMedications": "Leki", + "groupHistory": "Choroby i alergie", + "groupSensitive": "Dane wrażliwe", + "leafPatientIdentity": "Imię, data urodzenia, płeć, wzrost", + "leafInsurance": "Ubezpieczyciel i numer ubezpieczenia", + "leafGlucosePanel": "Panel glukozy (czas w zakresie, GMI, zmienność)", + "leafLabResults": "Wyniki badań", + "leafMedicationList": "Lista leków", + "leafMedicationAdministrations": "Każda zarejestrowana dawka", + "leafMedicationCompliance": "Przestrzeganie zaleceń", + "leafGlp1Therapy": "Terapia GLP-1 (historia dawek, działania niepożądane)", + "leafAllergies": "Alergie i nietolerancje", + "leafIllnessEpisodes": "Epizody chorobowe", + "leafFamilyHistory": "Wywiad rodzinny", + "leafMood": "Nastrój", + "leafCycle": "Cykl menstruacyjny", + "templateStandard": "Standardowy raport lekarski", + "applyStandard": "Zastosuj {template}", + "standardHint": "Jedno kliknięcie wypełnia to, czego zwykle wymaga wizyta u lekarza rodzinnego. Każdą pozycję można potem zmienić.", + "groupCount": "{on}/{total}", + "groupDisclosure": "Pokaż pozycje w {group}", + "sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.", + "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.", + "scopeCount": "{count} pozycji z {groups} obszarów", + "scopeSensitive": "w tym {names}" } } diff --git a/package.json b/package.json index 1f50c9823..b5a3e56ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.38", + "version": "1.32.39", "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", diff --git a/prisma/migrations/0275_report_selection/migration.sql b/prisma/migrations/0275_report_selection/migration.sql new file mode 100644 index 000000000..7325c56c2 --- /dev/null +++ b/prisma/migrations/0275_report_selection/migration.sql @@ -0,0 +1,29 @@ +-- The owner's saved report selection: the leaf-id inclusion list plus the +-- format / range / charts choices the export panel restores. +ALTER TABLE "users" ADD COLUMN "report_selection_json" JSONB; + +-- Retire the orphaned flat prefs column. No code outside its own route ever +-- read it — the route's docblock claimed the aggregator consulted it, which +-- was not true — and the route is removed in the same release. +ALTER TABLE "users" DROP COLUMN "doctor_report_prefs_json"; + +-- Retire the share-link FHIR scope columns. The face they described was never +-- built and their controls are gone. An inert column with a plausible name is +-- precisely what a later contributor rewires by mistake. +ALTER TABLE "clinician_share_links" DROP COLUMN "resource_types"; +ALTER TABLE "clinician_share_links" DROP COLUMN "allow_fhir_api"; + +-- Revoke every live RECORD share link minted before the selection model. Their +-- frozen scope is `{}` or a flat prefs blob, both of which resolved to +-- "everything except mood and cycle" — a scope nobody chose. Downgrading them +-- to something narrower would still be a scope nobody chose, so they close and +-- the owner re-mints from the sharing list, which shows each one in a +-- pick-again state with its label, window and expiry preserved. +-- +-- Documents-only shares are untouched: they carry an explicit empty report +-- scope and serve no health metric. +UPDATE "clinician_share_links" + SET "revoked_at" = NOW() + WHERE "revoked_at" IS NULL + AND "document_only" = false + AND "expires_at" > NOW(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b31a9a02..90ca68d2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -491,19 +491,22 @@ model User { // `MoodReminderDispatch` table (one row per user + local date). moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled") - // v1.4.25 W6c — Doctor-report section toggles. - // Shape: - // { "bp": boolean, - // "weight": boolean, - // "pulse": boolean, - // "bmi": boolean, - // "mood": boolean, // privacy-sensitive; default OFF - // "compliance": boolean, - // "sleep": boolean } - // Null = documented defaults (every section ON except mood). Mood is - // opt-in by default — the API layer drops mood entirely from the - // payload when the toggle is off so the data never leaves the DB row. - doctorReportPrefsJson Json? @map("doctor_report_prefs_json") + // The owner's saved report selection: the leaf-id inclusion list plus the + // format / range / charts choices the export panel restores. Shape: + // { "v": 2, + // "leaves": ["WEIGHT", "BLOOD_PRESSURE_SYS", "LAB_RESULTS", …], + // "format": "pdf" | "fhir" | "package", + // "rangeDays": number, + // "includeCharts": boolean } + // Null = never saved, which resolves to the EMPTY selection everywhere. + // Read by the export panel, the share-link create form, the FHIR REST face + // and the MCP doctor-visit surfaces. + // + // Replaces `doctorReportPrefsJson`, whose flat opt-out shape could not + // express "not chosen": an absent key resolved to included, so a caller who + // said nothing received the whole record. Membership is the whole contract + // here — absence is exclusion, and there is no defaults constant to drift. + reportSelectionJson Json? @map("report_selection_json") // v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob. // @@ -5350,8 +5353,8 @@ model PushAttempt { // `ApiToken.tokenHash`, keyed by `API_TOKEN_HMAC_KEY`); the plaintext is // returned to the owner exactly once at creation and never persisted. // -// The scope is FROZEN at creation: `rangeStart`, `sectionsJson` and -// `resourceTypes` are write-once and a share can NEVER widen later — only +// The scope is FROZEN at creation: `rangeStart` and `sectionsJson` are +// write-once and a share can NEVER widen later — only // `revokedAt`, `lastAccessAt` and `accessCount` mutate after mint. This // mirrors the append-only spirit of `ConsentReceipt`. `expiresAt` is // mandatory (capped at `SHARE_LINK_MAX_DAYS`, default 90) — there is no @@ -5378,13 +5381,10 @@ model ClinicianShareLink { rangeStart DateTime @map("range_start") /// Window end. Null = rolling ("up to now"); set = frozen snapshot window. rangeEnd DateTime? @map("range_end") - /// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON. + /// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at + /// mint, never widened. A blob this build cannot read resolves to the empty + /// selection, so a drifted row serves nothing rather than defaulting open. sectionsJson Json @map("sections_json") - /// FHIR resource types this link may serve (a subset of the catalogue), - /// e.g. `["Observation","MedicationStatement"]`. - resourceTypes String[] @map("resource_types") - /// Whether the FHIR API is reachable via this link at all (vs the view only). - allowFhirApi Boolean @default(false) @map("allow_fhir_api") /// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at /// create. When true the public view serves ONLY the attached documents and /// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling diff --git a/public/sw.js b/public/sw.js index 0a394fe63..945e43296 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.38"; + /* @sw-version-fallback */ "v1.32.39"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts new file mode 100644 index 000000000..ce02fa091 --- /dev/null +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -0,0 +1,207 @@ +/** + * Guard for the converted-value precision class (issue #627, second half). + * + * Every entry in the display-transform registry declares the `decimals` its + * unit is read at. Before v1.32.39 that number was carried all the way to the + * render layer and read by exactly one screen out of all the ones that show a + * converted value, so everywhere else the raw product of a kilogram and + * 2.20462262185 reached the surface unrounded. The fix moved the rounding into + * the conversion helpers, which means a surface gets a correctly-scaled number + * whether or not it remembers to ask. + * + * This file guards the two ways that could be undone: + * + * 1. A PROPERTY over the whole registry — every branch, swept over a spread + * of raw values, must come out already at its declared grain, and a + * non-rescaling branch must come out byte-identical. A property test is + * the honest shape here: the thing being guaranteed is arithmetic, and a + * structural sweep for "did the surface remember to round" is exactly the + * per-call-site discipline this change exists to abolish. + * 2. A STRUCTURAL freeze on the two ways to get an unrounded converted value + * anyway — importing the documented escape hatch, or hand-rolling + * `value * transform.factor + transform.offset` at a call site. Both sets + * are pinned to a named allowlist, so a new converted-value surface + * cannot quietly join them. + */ +import { readFileSync, globSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getDisplayTransform, + transformRescales, + TRANSFORMED_TYPES, +} from "@/lib/measurements/display-transform"; + +const SRC = join(process.cwd(), "src"); + +/** The registry itself owns the arithmetic; it is not a call site. */ +const REGISTRY = "lib/measurements/display-transform.ts"; + +/** + * The one module allowed to import the unrounded escape hatch. The target + * adapter needs the unrounded pound edge of a canonical guardrail so it can + * round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the + * 30 kg floor, which inverts to 29.98 kg and the server rejects it. + */ +const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; + +/** + * The surfaces allowed to read a transform's raw `factor` / `offset`. All four + * do it for the same reason: the chart takes the multiplier as a prop, folds + * it into its own series at the single read boundary, and formats the result + * itself. Everything those files RENDER directly goes through the rounding + * helpers. Adding a file here means arguing that its number never reaches a + * screen unrounded. + */ +const RAW_SCALE_SURFACES = [ + "app/page-client.tsx", + "app/insights/weight/page.tsx", + "components/insights/healthkit-metric-page.tsx", + "components/insights/device-score-tile.tsx", +]; + +/** Matches a `factor` / `offset` read off a display transform. */ +const RAW_SCALE_READ = + /\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/; + +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }).filter( + (rel) => + // Never walk the Prisma client, and skip the suites that legitimately + // exercise the raw arithmetic they are asserting about. + !rel.startsWith("generated/") && !rel.includes("__tests__/"), + ); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +describe("display-transform rounding guard — the registry property", () => { + const RAW_VALUES = [ + 0, 1, 1.3, 10, 36.6, 36.66666, 37, 78.45, 83.99981845627636, 84, 95.25, 210, + 1609.344, 8046.72, -0.5, -12.3456789, + ]; + + it("sweeps a non-empty registry", () => { + // Guards against a set that silently empties out (a green void). + expect(TRANSFORMED_TYPES.size).toBeGreaterThan(5); + }); + + it("declares a usable decimal count on every branch", () => { + for (const type of TRANSFORMED_TYPES) { + for (const preference of ["metric", "imperial"] as const) { + const { decimals } = getDisplayTransform(type, preference); + expect(Number.isInteger(decimals)).toBe(true); + expect(decimals).toBeGreaterThanOrEqual(0); + } + } + }); + + it("never returns a converted value finer than its declared decimals", () => { + const offenders: string[] = []; + for (const type of TRANSFORMED_TYPES) { + for (const preference of ["metric", "imperial"] as const) { + const transform = getDisplayTransform(type, preference); + if (!transformRescales(transform)) continue; + const scale = 10 ** transform.decimals; + for (const raw of RAW_VALUES) { + for (const [label, shown] of [ + ["absolute", applyDisplayTransform(raw, transform)], + ["delta", applyDisplayTransformDelta(raw, transform)], + ] as const) { + // Re-rounding at the declared grain must be a no-op: the value + // carries no more precision than the transform says it should. + if (Math.round(shown * scale) / scale !== shown) { + offenders.push( + `${type}/${preference} ${label} ${raw} → ${shown}`, + ); + } + } + } + } + } + expect(offenders).toEqual([]); + }); + + it("leaves a non-rescaling branch byte-identical", () => { + // The metric branch only relabels the symbol; the number it shows IS the + // stored number, and a stored 78.45 kg is not conversion noise. + const offenders: string[] = []; + for (const type of TRANSFORMED_TYPES) { + for (const preference of ["metric", "imperial"] as const) { + const transform = getDisplayTransform(type, preference); + if (transformRescales(transform)) continue; + for (const raw of RAW_VALUES) { + if (applyDisplayTransform(raw, transform) !== raw) { + offenders.push(`${type}/${preference} absolute ${raw}`); + } + if (applyDisplayTransformDelta(raw, transform) !== raw) { + offenders.push(`${type}/${preference} delta ${raw}`); + } + } + } + } + expect(offenders).toEqual([]); + }); + + it("leaves an untransformed type byte-identical", () => { + for (const type of ["PULSE", "BLOOD_GLUCOSE", "BODY_MASS_INDEX"]) { + const transform = getDisplayTransform(type, "imperial"); + for (const raw of RAW_VALUES) { + expect(applyDisplayTransform(raw, transform)).toBe(raw); + expect(applyDisplayTransformDelta(raw, transform)).toBe(raw); + } + } + }); +}); + +describe("display-transform rounding guard — the bypass freeze", () => { + it("sweeps a non-empty source tree", () => { + expect(sourceFiles().length).toBeGreaterThan(500); + }); + + it("confines the unrounded escape hatch to its documented importers", () => { + const importers = sourceFiles().filter( + (rel) => + rel !== REGISTRY && + read(rel).includes("applyDisplayTransformUnrounded"), + ); + expect(importers.sort()).toEqual([...UNROUNDED_IMPORTERS].sort()); + }); + + it("confines hand-rolled transform scaling to the chart-feeding surfaces", () => { + const offenders = sourceFiles().filter( + (rel) => + rel !== REGISTRY && + !UNROUNDED_IMPORTERS.includes(rel) && + RAW_SCALE_READ.test(read(rel)), + ); + expect(offenders.sort()).toEqual([...RAW_SCALE_SURFACES].sort()); + }); + + it("every allowlisted surface reads the raw scale to feed a chart", () => { + // The justification for holding the raw multiplier at all: the chart + // takes it as a prop. A file that stops passing it has no reason to be + // reading `.factor` any more and belongs back behind the helpers. + // The prop as it is actually passed — a bare substring would still + // match a renamed identifier that no longer reaches any chart. + const offenders = RAW_SCALE_SURFACES.filter( + (rel) => !/\bvalueScale=/.test(read(rel)), + ); + expect(offenders).toEqual([]); + }); + + it("every allowlisted surface still converts its rendered values", () => { + // The chart formats its own series; anything these files render outside + // the chart must come back through the preference hook. + const offenders = RAW_SCALE_SURFACES.filter( + (rel) => !read(rel).includes("useUnitDisplay()"), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts new file mode 100644 index 000000000..0aa41fec0 --- /dev/null +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -0,0 +1,149 @@ +/** + * Structural guard: a rendered scope control with no gating path fails the + * build, and a gating path with no control fails it too. + * + * The export panel once shipped twenty data switches, eight of which changed + * nothing at all — the wire fold read twelve of the keys it accepted and + * dropped the rest on the floor. Nothing failed; the control simply lied, and + * it lied for several releases because no test connected "the panel renders + * this" to "the export honours this". + * + * The selection model removed the fold, so the shape that defect lived in is + * gone. This file is the same connection retargeted at the leaf catalogue, + * which is now the one thing the picker, the aggregator, the PDF and the share + * view all read: + * + * 1. Every leaf the catalogue declares is reachable in the picker — the + * picker renders from the catalogue, so the check is that the catalogue + * places each leaf in exactly one group and the picker renders each group. + * 2. Flipping exactly one leaf changes the resolved selection. This is the + * assertion that would have caught all eight dead switches. + * 3. Every leaf is honoured somewhere downstream: as a measurement type the + * gate strips, or by name in the aggregator's own body. + * + * What it does NOT prove: that the excluded data is actually absent from a + * rendered artefact. That is behavioural and is covered where the artefacts are + * built — `src/app/api/export/health-record/__tests__/route.test.ts` opens the + * real PDF, the real bundle and the real zip. This guard is the cheap + * structural half that cannot be satisfied by writing a plausible-looking + * control. + * + * Mutation checks are recorded per assertion below. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + ALL_LEAF_IDS, + MEASUREMENT_LEAF_GROUP, + REPORT_GROUPS, + STRUCTURED_LEAF_GROUP, + isStructuredLeafId, + leafGroup, + type ReportLeafId, +} from "@/lib/report-selection/catalogue"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; + +const REPO_ROOT = join(__dirname, "..", ".."); +const COLLECT_PATH = join( + REPO_ROOT, + "src", + "lib", + "doctor-report", + "collect.ts", +); +const PICKER_PATH = join( + REPO_ROOT, + "src", + "components", + "settings", + "report-selection", + "report-scope-picker.tsx", +); + +const collectSource = readFileSync(COLLECT_PATH, "utf8"); +const pickerSource = readFileSync(PICKER_PATH, "utf8"); + +/** Strip comments so a leaf named only in prose does not count as read. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +describe("report scope gating — structural guard", () => { + it("reads a plausible catalogue", () => { + // Sanity floor: a silently degraded catalogue would pass everything below + // vacuously. + expect(ALL_LEAF_IDS.length).toBe(90); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(13); + }); + + it("places every leaf in exactly one group", () => { + const seen = new Map(); + for (const group of REPORT_GROUPS) { + for (const leaf of group.leaves) { + seen.set(leaf, (seen.get(leaf) ?? 0) + 1); + } + } + const duplicated = [...seen.entries()].filter(([, n]) => n > 1); + const missing = ALL_LEAF_IDS.filter((leaf) => !seen.has(leaf)); + expect(duplicated, "a leaf appears in more than one group").toEqual([]); + expect(missing, "a leaf is in no group and so is unreachable").toEqual([]); + }); + + it("renders every group the catalogue declares", () => { + // The picker maps over `REPORT_GROUPS` and splits the fenced tier out by + // id, so this is the check that the split cannot silently drop a group. + expect(pickerSource).toContain("REPORT_GROUPS.map"); + expect(pickerSource).toContain("SENSITIVE_GROUP_ID"); + }); + + it("makes flipping any single leaf change the resolved selection", () => { + const all = selectionFromLeaves(ALL_LEAF_IDS); + const inert: ReportLeafId[] = []; + for (const leaf of ALL_LEAF_IDS) { + const without = selectionFromLeaves( + ALL_LEAF_IDS.filter((l) => l !== leaf), + ); + if ( + without.has(leaf) || + without.leaves.length !== all.leaves.length - 1 + ) { + inert.push(leaf); + } + } + expect( + inert, + "these leaves cannot be individually withheld, so their control would lie", + ).toEqual([]); + }); + + it("has something downstream read every leaf", () => { + // A leaf is honoured either as a measurement type — the gate strips those + // by set membership over the whole enum — or by name in the aggregator. + const body = stripComments(collectSource); + const unread = ALL_LEAF_IDS.filter( + (leaf) => isStructuredLeafId(leaf) && !body.includes(`"${leaf}"`), + ); + expect( + unread, + "these structured leaves are never read in the aggregator, so switching " + + "them off would change nothing that leaves the instance", + ).toEqual([]); + }); + + it("keeps every sensitive leaf in the fenced tier", () => { + for (const leaf of [ + "MOOD", + "CYCLE", + "FAMILY_HISTORY", + "PHQ9_SCORE", + "GAD7_SCORE", + "WHO5_SCORE", + "SCI_SCORE", + ] as ReportLeafId[]) { + expect(leafGroup(leaf), `${leaf} must be fenced`).toBe("sensitive"); + } + }); +}); diff --git a/src/__tests__/report-selection-egress-guard.test.ts b/src/__tests__/report-selection-egress-guard.test.ts new file mode 100644 index 000000000..0601165d1 --- /dev/null +++ b/src/__tests__/report-selection-egress-guard.test.ts @@ -0,0 +1,83 @@ +/** + * Structural guard: every caller of the doctor-report aggregator is named here + * with the source of its selection. + * + * The aggregator's third parameter is required and brand-typed, so a new + * egress path cannot compile without naming a selection source. This is the + * second half of that: it cannot be ADDED without a human writing down which + * source, in this list, which is the moment a reviewer asks the consent + * question. Modelled on `bearer-scope-enforcement-guard.test.ts`. + * + * Adding a caller fails the build until the list is updated deliberately. + * + * Mutation check: add a `collectDoctorReportData(` call anywhere under `src/` + * that is not in the list → "the set of aggregator callers is frozen" goes red + * naming the file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const SRC = join(__dirname, ".."); +const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); + +/** + * Every production call site, with the source of its selection. A path here + * that no longer calls the aggregator is as much a failure as one that does + * and is missing — a stale entry is how a list stops being read. + */ +const CALLERS: Record = { + "app/api/export/health-record/route.ts": + "the request body's `selection` field, minted through selectionFromRequest", + "lib/clinician-share/share-view-data.ts": + "the share link's frozen sectionsJson, minted through selectionFromStoredBlob", + "lib/fhir/rest.ts": "the owner's saved profile (resolveSavedSelection)", + "lib/mcp/resources.ts": "the owner's saved profile (resolveSavedSelection)", + "lib/mcp/prompts.ts": "the owner's saved profile (resolveSavedSelection)", +}; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (name.endsWith(".ts") || name.endsWith(".tsx")) out.push(p); + } + return out; +} + +/** Strip comments so a mention in prose is not read as a call. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +describe("doctor-report aggregator egress", () => { + it("keeps the set of aggregator callers frozen", () => { + const found: string[] = []; + for (const file of walk(SRC)) { + const rel = file.slice(SRC.length + 1).replace(/\\/g, "/"); + // The aggregator's own module and its public surface are not callers. + if (rel === "lib/doctor-report/collect.ts") continue; + if (rel === "lib/doctor-report-data.ts") continue; + const body = stripComments(readFileSync(file, "utf8")); + if (body.includes("collectDoctorReportData(")) found.push(rel); + } + + const expected = Object.keys(CALLERS).sort(); + expect( + found.sort(), + "Every surface that assembles the whole record has to name where its " + + "selection came from. Add the file to CALLERS with its source, or " + + "remove the call.", + ).toEqual(expected); + }); + + it("names a real selection source for each caller", () => { + for (const [path, source] of Object.entries(CALLERS)) { + expect( + source.length, + `${path} has no stated selection source`, + ).toBeGreaterThan(10); + } + }); +}); diff --git a/src/app/__tests__/i18n-boot-script-strategy.test.ts b/src/app/__tests__/i18n-boot-script-strategy.test.ts new file mode 100644 index 000000000..b736ab720 --- /dev/null +++ b/src/app/__tests__/i18n-boot-script-strategy.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Root layout — i18n boot-script ordering pin. + * + * The locale catalog is a ~370 KB separate asset, and the client bundle + * needs it in hand for its FIRST render or `t()` resolves raw keys against + * the server's real text (React error #418, plus a visible frame of + * `nav.skipToContent` before the provider backfills). + * + * A plain `