diff --git a/CHANGELOG.md b/CHANGELOG.md index 388fe5bec..84c2a523f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,87 @@ # Changelog +## [1.42.2] — 2026-10-11 + +Actionable notifications, record search in the command palette, automatic +intake recording, and fixes for Oura, non-UTC databases, documents and +Google Health backfills. + +### Added + +- **Notification tap targets (#1203).** One map resolves a target path per + event type; ntfy carries it as `Click`, Telegram as an "Open in HealthLog" + button on public https (text link on plain http), web push as `url`, + webhook and Gotify as a URL field, email as a link line, APNs as `url`. + No target for guardian deliveries, discreet cycle mode or the silent sync + push. ntfy titles and action labels outside ASCII are sent RFC 2047 + encoded. +- **Notification content preference.** `notificationPrefs.content.detail` + (`generic` default, `detailed`) on `PATCH /api/auth/me/notification-prefs`; + with `detailed` the morning briefing title carries the score. +- **Mood quick answers on ntfy.** Three actions post to + `POST /api/notifications/action/[token]`: an HMAC token bound to user and + answer, valid six hours, single use per reminder, rate limited before + verification, recorded once in the idempotency table so a purged entry + cannot be answered again. Mood entries record source `NOTIFICATION`. A + reminder tapped while acting in another record opens the mood page + without the entry sheet. ntfy messages are published as JSON. +- **Record search (#1117).** `POST /api/search` (query in the body, so it + never appears in a URL or proxy log; read-level access, one search at a + time per account, 20 a minute) searches visits and + procedures, conditions, documents, vaccinations, lab results, medications, + symptoms, workouts and notes, decrypting encrypted text in bounded batches + with a shared time budget, and reports `truncated` when a bound stopped a + source. Matching is case-, accent- and umlaut-spelling-blind and shared + with the MCP `search` tool. The palette lists hits under "In your records"; + each hit deep-links to its entry. Migration 0397 adds a partial index on + live readings that carry a note. +- **Open in new tab (#1202)** for PDF and image originals in the document + vault. +- **Record doses automatically.** `Medication.autoMarkTaken` (with + `autoMarkTakenSince`, migration 0395): a job every 15 minutes records each + due dose as taken from the moment the option is switched on, through the + schedule engine, never over a dose taken, skipped or deleted by hand, with + the same stock and rollup follow-ups as a manual take. Intakes carry + `autoRecorded`; no reminders are sent for such a medication. List and + detail responses carry the resolved `remindersActive` + (false also once a course has ended). Rolling cadences step past slots the + job will not record, and the card shows the same next slot. + +### Fixed + +- **Oura token exchange (#1204).** Codes are exchanged at + `moi.ouraring.com/oauth/v2/ext/oauth-token`; a code issued by that server is + never sent to the legacy endpoint. Refresh falls back to + `api.ouraring.com/oauth/token` once. The callback validates `iss` (RFC 9207). +- **Non-UTC database time zone (#1200).** Every connection (Prisma, pg-boss, + lock and maintenance clients) runs with `TimeZone=UTC`, with a `SET` + fallback when session options are disabled; raw SQL reads the clock as + `NOW() AT TIME ZONE 'UTC'`; boot warns when the session zone is not UTC. +- **Google Health backfill (#1194).** A slice that saved progress enqueues + the next slice and ends without `job.run.failed`; a slice without saved + progress still fails, and the chain stops after collections + 2 slices. +- **Admin AI spend card** sums today's spend on each account's local day. +- **Daily AI budget** is keyed on the account's local day. +- **Day view:** Escape inside the date picker no longer closes the day. + +### Changed + +- Docked strips run the full height, the header search is a magnifier icon, + `g p` toggles the day panel. +- Without a zoom in the URL the timeline fits its window to the record: three + months, clamped to the first entry (at least 28 days) for a young record, + and everything when the last three months are empty. An empty window offers + "Show everything" when older entries exist. +- The explanatory line under day-linked charts is gone. + +### API + +- New: `POST /api/search`, `POST /api/notifications/action/{token}`. +- Medication: `autoMarkTaken`, `autoMarkTakenSince`, `remindersActive`; + intakes and sync intake upserts: `autoRecorded`. +- `notificationPrefs.content.detail` on the notification preferences. +- APNs payloads carry `url` on every visible push. + ## [1.42.1] — 2026-10-10 Fixes for Google Health syncing and the measurement maintenance run. diff --git a/docs/README.md b/docs/README.md index b3e5c1186..dfe00df1a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,10 @@ notes, and machine-readable specs that ship alongside the source: managed profiles for somebody with no login and the guardians who look after them, what revocation does and does not undo, and which trust properties of the instance are unchanged by any of it. +- [`using/`](./using/) — notes on using the app that sit close to the + code. [`using/search.md`](./using/search.md) covers the command palette's + record search: opening it, what it finds, how matching works, and its + limits on very large and shared records. - [`migration/`](./migration/) — migration notes. Currently only v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and . diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..1754488a8 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.42.1 + version: 1.42.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -13103,6 +13103,7 @@ paths: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION - in: query name: from schema: @@ -21632,6 +21633,56 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/notifications/action/{token}: + post: + tags: + - Notifications + summary: Log a quick answer tapped inside a notification + description: "Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is + the only credential. The token is bound to one user and one answer, expires six hours after the reminder was + sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the + token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off." + parameters: + - in: path + name: token + schema: + type: string + description: Signed action token from the notification's URL. + required: true + description: Signed action token from the notification's URL. + responses: + "200": + description: The answer was logged. + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationActionResponse" + "401": + description: Invalid token (forged, altered, or unknown user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": + description: The mood module is turned off for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: This reminder was already answered. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "410": + description: The answer expired. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/notifications/preferences: get: tags: @@ -23677,6 +23728,52 @@ paths: "404": *a69 "422": *a4 "429": *a5 + /api/search: + post: + tags: + - Records + summary: Search the record + description: "One search over the record: visits and procedures, conditions, documents (title, file name and indexed + text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the + notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each + carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed + as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source + newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query + travels in the JSON body, never in the URL, and is never logged. Rate limited per acting account (20 a minute), + and one search at a time: a newer search by the same account stops the one before it, which then answers 409 + `search.superseded`. A grant scoped to some sections of the record does not open it, like the timeline." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + q: + type: string + minLength: 2 + maxLength: 100 + description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue + or ss. + limit: + default: 5 + description: Most hits per group. + type: integer + minimum: 1 + maximum: 20 + required: + - q + responses: + "200": + description: The hits, grouped by kind. + content: + application/json: + schema: + $ref: "#/components/schemas/SearchEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/bugreport: post: security: [] @@ -26244,6 +26341,15 @@ components: does not know the field cannot have seen the stored schedule, which is served in `recordedSchedules`). Name `trackIntake` to change the schedule of such a medication." type: boolean + autoMarkTaken: + description: "v1.42.2 — record each scheduled dose as taken automatically. Default false. When true the server records a + taken intake (`autoRecorded: true`) for every scheduled slot once its time has passed, from the moment the + option was switched on (`autoMarkTakenSince`); slots before that are never filled. A slot the person already + took, skipped or removed is left alone, and the person can still change or skip any dose by hand. While on, + no medication reminder is sent for this medication on any channel; a client that arms local reminders should + not arm them either. Has no effect on an as-needed medication or one whose intake tracking is off. Omitted + on update leaves the current value." + type: boolean schedules: type: array items: @@ -26647,6 +26753,15 @@ components: does not know the field cannot have seen the stored schedule, which is served in `recordedSchedules`). Name `trackIntake` to change the schedule of such a medication." type: boolean + autoMarkTaken: + description: "v1.42.2 — record each scheduled dose as taken automatically. Default false. When true the server records a + taken intake (`autoRecorded: true`) for every scheduled slot once its time has passed, from the moment the + option was switched on (`autoMarkTakenSince`); slots before that are never filled. A slot the person already + took, skipped or removed is left alone, and the person can still change or skip any dose by hand. While on, + no medication reminder is sent for this medication on any channel; a client that arms local reminders should + not arm them either. Has no effect on an as-needed medication or one whose intake tracking is off. Omitted + on update leaves the current value." + type: boolean schedules: type: array items: @@ -26856,7 +26971,7 @@ components: - ko today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). - Format: `YYYY-MM-DD`. Defaults to the server's UTC day." + Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." type: string pattern: ^\d{4}-\d{2}-\d{2}$ required: @@ -27417,6 +27532,14 @@ components: properties: clientManaged: type: boolean + content: + type: object + properties: + detail: + type: string + enum: + - generic + - detailed baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -28816,6 +28939,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: description: "iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable across app restarts — an object description or memory address (``, `0x12ab34cd`) is a new value @@ -29336,6 +29460,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: type: string minLength: 1 @@ -34373,19 +34498,21 @@ components: mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; - nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, - nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — - onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, - onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, - onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, - onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, - passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner — - practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, - profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; - profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken; - record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, - report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link - — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + notification — notification.action.already_used, notification.action.expired, + notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid, + nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, + nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, + onboarding.complete.invalid, onboarding.complete.recordNotFound, + onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, + onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired, + passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records — + personal_records.invalid_query; practitioner — practitioner.invalid; profile — + profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, + profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, + profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited; + report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, + report-selection.leaves.unknown; route — route.retired; search — search.invalid; share-link — + share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, @@ -37138,6 +37265,13 @@ components: type: boolean trackIntake: type: boolean + autoMarkTaken: + type: boolean + autoMarkTakenSince: + anyOf: + - type: string + minLength: 1 + - type: "null" category: type: string maxLength: 64 @@ -37795,6 +37929,8 @@ components: type: boolean autoMissed: type: boolean + autoRecorded: + type: boolean attributionSource: type: string enum: @@ -44205,6 +44341,9 @@ components: type: boolean source: type: string + autoRecorded: + type: boolean + description: v1.42.2 — the server recorded this dose because the medication has `autoMarkTaken` on. Counts as taken. syncVersion: type: integer minimum: 0 @@ -44221,6 +44360,7 @@ components: - takenAt - skipped - source + - autoRecorded - syncVersion - updatedAt additionalProperties: false @@ -46620,6 +46760,12 @@ components: type: boolean description: True when the nightly cron closed the slot as missed (no user action). Auto-missed rows count against adherence but never consume inventory. + autoRecorded: + type: boolean + description: v1.42.2 — true when the server recorded this dose because the medication has `autoMarkTaken` on, not the + person. It counts as taken everywhere (adherence, history, Coach) and consumed inventory like a manual take. + Any take or skip the person records on the row clears it. Clients label such a dose "recorded + automatically". attributionSource: type: string enum: @@ -46701,6 +46847,7 @@ components: - takenAt - skipped - autoMissed + - autoRecorded - attributionSource - source - idempotencyKey @@ -47564,6 +47711,13 @@ components: 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" + remindersActive: + type: boolean + description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when + `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or + later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and + `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local + reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored." nextDueAt: anyOf: - type: string @@ -47604,6 +47758,23 @@ components: any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update." + autoMarkTaken: + type: boolean + description: "v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before + the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, + `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person + took, skipped or removed is never touched, and they can still change or skip any dose. No medication + reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm + them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is + switched off." + autoMarkTakenSince: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded + automatically. NULL while the option is off. Read-only — set by the server on the switch. createdAt: type: string format: date-time @@ -47735,6 +47906,7 @@ components: - rxNormCode - pausedAt - snoozedUntil + - remindersActive - nextDueAt - nextDueOverdue - startsOn @@ -47742,6 +47914,8 @@ components: - oneShot - asNeeded - trackIntake + - autoMarkTaken + - autoMarkTakenSince - createdAt - updatedAt - schedules @@ -47906,6 +48080,9 @@ components: 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" + remindersActive: + type: boolean + description: v1.42.2 — whether the server sends dose reminders for this medication. See the `Medication` component. nextDueAt: description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules @@ -47957,6 +48134,23 @@ components: any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update." + autoMarkTaken: + type: boolean + description: "v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before + the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, + `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person + took, skipped or removed is never touched, and they can still change or skip any dose. No medication + reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm + them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is + switched off." + autoMarkTakenSince: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded + automatically. NULL while the option is off. Read-only — set by the server on the switch. createdAt: type: string format: date-time @@ -48058,11 +48252,14 @@ components: - rxNormCode - pausedAt - snoozedUntil + - remindersActive - startsOn - endsOn - oneShot - asNeeded - trackIntake + - autoMarkTaken + - autoMarkTakenSince - createdAt - updatedAt - schedules @@ -49988,6 +50185,9 @@ components: type: boolean autoMissed: type: boolean + autoRecorded: + type: boolean + description: v1.42.2 — the dose was recorded automatically (`autoMarkTaken`), not by the person. doseTaken: anyOf: - type: string @@ -50013,6 +50213,7 @@ components: - takenAt - skipped - autoMissed + - autoRecorded - doseTaken - source additionalProperties: false @@ -52677,6 +52878,19 @@ components: - nudgeRoutine - nudgeFrequency additionalProperties: false + content: + type: object + properties: + detail: + type: string + enum: + - generic + - detailed + description: What a notification may show. "generic" (default) names what it is about with no health figure; "detailed" + adds figures such as the morning score. Tap targets and quick answers are the same in both. + required: + - detail + additionalProperties: false updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or @@ -52689,6 +52903,7 @@ components: - mood - cycle - coach + - content additionalProperties: false PatchNotificationPrefsResponse: type: object @@ -65116,6 +65331,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: anyOf: - type: string @@ -78271,6 +78487,42 @@ components: required: - ok additionalProperties: false + NotificationActionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationActionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NotificationActionResult: + type: object + properties: + logged: + type: boolean + const: true + action: + type: string + enum: + - mood-good + - mood-okay + - mood-bad + description: "The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the + five-point mood scale." + required: + - logged + - action + additionalProperties: false + description: A quick answer from a notification, logged as a mood entry. GetNotificationPreferences: type: object properties: @@ -82204,6 +82456,137 @@ components: - data - error additionalProperties: false + SearchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SearchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SearchResponse: + type: object + properties: + groups: + type: array + items: + type: object + properties: + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + hits: + type: array + items: + $ref: "#/components/schemas/SearchHit" + more: + type: boolean + required: + - kind + - hits + - more + additionalProperties: false + truncated: + type: boolean + description: The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries + may match and are not listed. + required: + - groups + - truncated + additionalProperties: false + SearchHit: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - visit + - condition + - document + - vaccination + - lab + - medication + - symptom + - workout + - note + title: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + context: + type: array + items: + $ref: "#/components/schemas/SearchMarkedText" + snippet: + anyOf: + - $ref: "#/components/schemas/SearchMarkedText" + - type: "null" + day: + anyOf: + - type: string + - type: "null" + path: + type: string + description: In-app path that opens the record, never an API path. + source: + anyOf: + - type: string + enum: + - mood + - measurement + - illness + - type: "null" + required: + - id + - kind + - title + - context + - snippet + - day + - path + - source + additionalProperties: false + SearchMarkedText: + type: object + properties: + text: + type: string + marks: + type: array + items: + type: array + prefixItems: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Half-open [start, end) character ranges of `text` a query word matched. + required: + - text + - marks + additionalProperties: false __schema0: anyOf: - type: string @@ -82646,6 +83029,14 @@ components: 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" + remindersActive: + description: "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders + for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has + not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked + (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side + reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. + Read-only — computed, not stored." + type: boolean nextDueAt: description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules @@ -82697,6 +83088,23 @@ components: any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update." + autoMarkTaken: + type: boolean + description: "v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before + the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, + `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person + took, skipped or removed is never touched, and they can still change or skip any dose. No medication + reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm + them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is + switched off." + autoMarkTakenSince: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded + automatically. NULL while the option is off. Read-only — set by the server on the switch. createdAt: type: string format: date-time @@ -82738,6 +83146,8 @@ components: - oneShot - asNeeded - trackIntake + - autoMarkTaken + - autoMarkTakenSince - createdAt - updatedAt - schedules diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index c55033afd..9967227e6 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -77,6 +77,26 @@ process (`pnpm dev` against a host Postgres) or a **custom compose** that does not derive it — in that case uncomment and set `DATABASE_URL` explicitly in `.env` (see the commented example there). +### Using an existing or shared Postgres + +HealthLog can live in a database on a Postgres instance you already run. +It stores every time as UTC and pins each connection it opens to UTC +(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the +server's or the database's own `timezone` setting does not matter: an +instance set to `Europe/Berlin` works the same as one set to UTC. The app +checks the setting at startup and logs a warning naming the zone if a +connection still reports something else. + +Behind PgBouncer with `DATABASE_SESSION_OPTIONS_DISABLED=1` (see +[Scaling](./scaling.md#postgres-connection-pool-sizing)) the app pins the +zone with `SET TIME ZONE 'UTC'` on each new connection instead; PgBouncer +keeps that setting per client in transaction pooling too. If the startup +warning still appears, set the database default as well: + +```sql +ALTER DATABASE healthlog SET timezone TO 'UTC'; +``` + ## 3. Bring the stack up ```bash diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index c631f5138..509ac3351 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -179,6 +179,12 @@ HealthLog publishes to that topic. **Server URL** (defaults to `https://ntfy.sh`; point it at your own server if you self-host ntfy) and **Topic**, then save. +HealthLog publishes each message as JSON to the server's root URL (the topic +travels in the body, see ntfy's +[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles +and button labels in any language arrive as written. A reverse proxy in front +of your own ntfy has to pass `POST /` through, not only `POST /`. + If your ntfy server requires auth, an access token can be attached; the server URL is validated at save time and dialled through the egress guard (`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own @@ -234,6 +240,8 @@ choice existed keeps sending: message are masked as they are on every other channel. - `priority` is a word: `urgent` for an event flagged urgent, `high` for medication reminders, `default` for everything else. +- `url`, when `APP_URL` is set, is the page the notification leads to (see + [Tap targets](#what-tapping-a-notification-opens)). Absent otherwise. **Gotify** sends the body Gotify's `POST /message` endpoint expects: @@ -450,6 +458,112 @@ Two gotchas worth knowing before you blame a stale token: > in the web UI. If you only run the PWA, ignore it entirely — your > medication reminders come from the server cron over the channels above. +## Tap targets, content and quick answers + +### What tapping a notification opens + +Every notification leads somewhere in the app. The server decides where, +once per event type, and every channel carries the same target: + +| Notification | Opens | +| -------------------------------------------- | ---------------------------------------------------------------------------------- | +| Mood reminder | `/mood`, with the mood entry sheet already open | +| Morning briefing | the dashboard | +| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) | +| Check-up and measurement reminders | `/checkups` | +| Proactive Coach nudge | the Coach conversation it started | +| Cycle reminders | `/cycle` (nothing with discreet notifications on) | +| New sign-in | Settings, Security | +| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) | +| Illness red flag | the illness episode | +| Other system alerts | the dashboard, or the admin status page for operator alerts | + +How each channel carries it: + +- **ntfy**: the message's `click` field, so tapping the notification opens + the page. +- **Telegram**: an **Open in HealthLog** button under the message, below any + action buttons. Telegram only accepts `https` button links on a public + host, so a plain-HTTP instance gets a text link instead, and an instance + on `localhost` gets no link. +- **Web Push**: the service worker opens the page, or focuses an open + HealthLog tab and navigates it there. +- **APNs**: the path rides the payload as `url`. +- **Webhook**: the generic body gains a `url` field; the Gotify body gains + `extras["client::notification"].click.url`, which the Gotify app opens. +- **Email**: a link line under the message. + +ntfy, Telegram, the webhook and email need an absolute link, built from +`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as +before, just without a link. A notification about a profile you look after +as a Guardian carries no deep link, because your session opens on your own +record. + +### Health figures in notifications + +Settings, Notifications, **Health figures in notifications** decides what a +notification may say on a lock screen: + +- **Off** (the default): a notification says what it is about and shows no + health figure. The morning briefing reads "Your morning briefing". +- **On**: notifications add their figures. The morning briefing becomes + "Morning score 78" with the day's one-line summary underneath. + +Tap targets and quick answers work the same either way. The setting is +stored as `content.detail` (`generic` or `detailed`) in the notification +preferences (`PATCH /api/auth/me/notification-prefs`) and travels with the +account backup. + +### Quick answers on the mood reminder + +On **ntfy** the mood reminder carries three buttons, **Good**, **Okay** and +**Bad**. Tapping one logs a mood entry straight from the notification, +without opening the app, and clears the notification. They map onto the +five-point mood scale as Good = 4, Okay = 3, Bad = 2; the outer points +stay in the app. The entry is marked with the source **Notification**. + +Each button calls `POST /api/notifications/action/`. The +token is the only credential, so it is built to be harmless on its own: + +- it is signed with `API_TOKEN_HMAC_KEY` and names one account and one + answer, so it cannot be moved to another account or another answer; +- it expires six hours after the reminder was sent; +- the three answers of one reminder are one use between them: the first + tap logs, every later tap on that reminder is refused, and that stays + true when the logged entry is deleted and purged from the trash before + the six hours are up; +- the route is rate-limited before it reads the token, refuses when the + mood module has been turned off, and the token is scrubbed from logs. + +The buttons need `APP_URL`, because the ntfy app calls the URL from your +phone. Your phone has to reach the instance at that address. + +What the token cannot protect against is the topic itself. The button URLs, +token included, travel through your ntfy server inside the message, and the +message is delivered to everyone subscribed to the topic. For the six hours +a token is valid, anyone who can read the topic can log that one answer on +your behalf. On the public `ntfy.sh` the topic name is the only access +control, so: + +- use a long, random topic name and do not share it; +- better, run your own ntfy server with access control and give HealthLog + an access token that may only publish to your topic; +- to invalidate every quick-answer token at once, rotate + `API_TOKEN_HMAC_KEY`. The same key also hashes API tokens and session + cookies, so rotating it invalidates every issued API token and signs out + every browser session and the iOS app as well (see + [getting started](./getting-started.md)). + +**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5 +scale, a note and a "later" button. + +**Web Push** offers no buttons. Most browsers show at most two actions, +which cannot carry a three-way answer, and Safari on iPhone shows none. +Tapping the notification opens the mood entry sheet instead. + +**APNs**: the iOS app draws its own buttons from the notification category +it registers for the mood reminder. + ## Which channel should I pick? | Your setup | Recommended channel | diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 2512ecad9..c39d30b5a 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -188,13 +188,16 @@ already`. Either raise `max_connections` on the Postgres side, lower `DATABASE_POOL_MAX`, or front the database with PgBouncer in transaction-pooling mode. -The app sets its statement timeout and `work_mem` on every connection -through the libpq `options` startup parameter. PgBouncer refuses that +The app sets its statement timeout, `work_mem` and `TimeZone=UTC` on every +connection through the libpq `options` startup parameter. PgBouncer refuses that parameter (`unsupported startup parameter: options`) unless it is listed in `ignore_startup_parameters`. Either list it there, or set `DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session settings; set the timeout and `work_mem` on the database role instead (`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them. +The time zone is the exception: with the options off the app pins it with +`SET TIME ZONE 'UTC'` on each new connection, which PgBouncer tracks per +client, and warns at startup if a connection still reports another zone. The measurement maintenance job (Admin, System status) then applies its own settings with `SET` after connecting. Its concurrent index rebuild needs a session, so run it through a session-mode pool or a direct connection. diff --git a/docs/using/search.md b/docs/using/search.md new file mode 100644 index 000000000..767d55f6f --- /dev/null +++ b/docs/using/search.md @@ -0,0 +1,55 @@ +# Searching your record + +The command palette finds pages and settings, and it searches what you have +recorded. + +## Opening it + +Press **⌘K** on a Mac or **Ctrl K** elsewhere, or tap the magnifier at the +top right, and start typing. + +## What it finds + +Below the pages and settings, a section **In your records** lists the +entries that match what you typed: + +- visits (procedures included) +- conditions +- documents, by title, file name and the text read from them +- vaccinations +- lab results +- medications +- symptoms +- workouts +- notes on mood entries, readings and illness days + +Each hit opens where the entry lives: a visit on the check-ups page, a lab +result on its analyte, a note on a mood entry or a reading on its day, a note +on an illness day in its illness. + +## How matching works + +Case and accents do not matter, so "schulter", "Schulter" and "Schülter" +find the same entries. Umlauts can be typed as `ae`, `oe` and `ue` (and `ß` +as `ss`), which helps on a keyboard without them. + +## Very large records + +The search reads each kind of entry newest first and stops after a fixed +number of entries and a short time limit, so it stays quick on a record that +holds years of data. When it stopped before reaching the oldest entries, the +list says so. A more specific search term usually finds an older entry. + +## Shared records + +When someone has shared their whole record with you, the search works inside +that record too. An account shared with you section by section cannot be +searched: the search lays every section side by side, which a partial share +does not open. + +## Privacy + +What you type is sent to your instance in the body of the request, not in the +address, so it does not end up in a reverse proxy's access log or in the +browser history. The server does not log the search text either, only its +length and how many hits it found. diff --git a/e2e/coach-open-at-end.spec.ts b/e2e/coach-open-at-end.spec.ts index ef3ef4fd1..a86f0426c 100644 --- a/e2e/coach-open-at-end.spec.ts +++ b/e2e/coach-open-at-end.spec.ts @@ -185,12 +185,26 @@ test.describe("Coach: a saved conversation opens at its end", () => { waitUntil: "domcontentloaded", }); - const thread = page.locator('[data-slot="coach-message-thread"]'); - await expect(thread).toBeVisible({ timeout: 15_000 }); + // The empty state carries the same `coach-message-thread` slot, so the + // thread being visible says nothing about the conversation having + // arrived; the height used to be read from the empty state on a slow + // run. Wait for the frame only a loaded thread renders, and for every + // stored answer in it. + await expect( + page.locator('[data-slot="coach-message-thread-frame"]'), + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.locator('[data-slot="coach-assistant-turn"]'), + ).toHaveCount(8, { timeout: 15_000 }); + const thread = page.locator( + '[data-slot="coach-message-thread-frame"] [data-slot="coach-message-thread"]', + ); // The thread scrolls at all: otherwise "at the end" proves nothing. - expect( - await thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), - ).toBe(true); + await expect + .poll(() => + thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), + ) + .toBe(true); const chart = page.locator( '[data-slot="coach-result-chart"][data-ref="r1"]', diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts index 26d24eac5..cae66b948 100644 --- a/e2e/command-palette.spec.ts +++ b/e2e/command-palette.spec.ts @@ -15,7 +15,11 @@ import { expect, test } from "./setup/test"; * - the top bar carries a magnifier at every width, named "Search", * with a tooltip that gives the shortcut; on a phone it opens a * full-height sheet with the field focused; - * - the open palette passes axe. + * - the open palette passes axe; + * - a hit from the record search ("In your records", answered by a route + * mock so the shared account's records never move) lists after the local + * results, Enter on it opens its page, and a visit lands on the visits + * tab; on a phone the hit fits the width. * * Stable hooks only: `command-palette` (+ `data-variant`), * `command-palette-input`, `command-palette-trigger`, @@ -26,6 +30,53 @@ import { expect, test } from "./setup/test"; test.use({ storageState: STORAGE_STATE_PATH }); const palette = (page: Page) => page.locator('[data-slot="command-palette"]'); + +/** Answer `POST /api/search` with one visit hit, and count the requests. */ +async function mockRecordSearch(page: Page): Promise { + const queries: string[] = []; + await page.route("**/api/search", async (route) => { + // The query rides the JSON body, never the URL. + expect(route.request().method()).toBe("POST"); + expect(route.request().url()).not.toContain("?"); + const body = route.request().postDataJSON() as { q?: string } | null; + queries.push(body?.q ?? ""); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + groups: [ + { + kind: "visit", + more: false, + hits: [ + { + id: "visit:e2e-visit", + kind: "visit", + title: { text: "Arthroscopy", marks: [] }, + context: [ + { text: "Shoulder (left)", marks: [[0, 8]] }, + { text: "A practitioner with a long name", marks: [] }, + ], + snippet: { + text: "Follow-up for the shoulder after the operation, physiotherapy twice a week", + marks: [[18, 26]], + }, + day: "2026-03-02", + path: "/checkups?visit=e2e-visit", + source: null, + }, + ], + }, + ], + truncated: false, + }, + error: null, + }), + }); + }); + return queries; +} const input = (page: Page) => page.locator('[data-slot="command-palette-input"]'); @@ -110,6 +161,47 @@ test.describe("command palette", () => { await expect(input(page)).toHaveValue("timeline"); }); + test("a hit from the record opens its page", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + const queries = await mockRecordSearch(page); + // Start on the page the hit opens, on another tab: the link has to move + // the open page, not only a fresh one. + await page.goto("/checkups"); + await shellReady(page); + await expect( + page.locator('[data-testid="checkups-tab-visits"]'), + ).toHaveAttribute("aria-selected", "false"); + await page.keyboard.press("ControlOrMeta+k"); + await input(page).fill("shoulder"); + const hit = page.locator('[data-entry="record:visit:e2e-visit"]'); + await expect(hit).toBeVisible(); + // Asked once the typing paused, not once per letter. + expect(queries).toEqual(["shoulder"]); + await expect( + page.locator('[data-group="records"] mark').first(), + ).toHaveText("Shoulder"); + + const axe = await new AxeBuilder({ page }) + .include('[data-slot="command-palette"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(axe.violations).toEqual([]); + + // The arrows reach it after the local options, and Enter opens it. + for (let i = 0; i < 40; i++) { + const active = await input(page).getAttribute("aria-activedescendant"); + if (active && (await hit.getAttribute("id")) === active) break; + await page.keyboard.press("ArrowDown"); + } + await expect(hit).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/); + await expect(palette(page)).toHaveCount(0); + await expect( + page.locator('[data-testid="checkups-tab-visits"]'), + ).toHaveAttribute("aria-selected", "true"); + }); + test("the top bar: a magnifier on a wide desktop, with the shortcut in its tooltip", async ({ page, }) => { @@ -161,4 +253,23 @@ test.describe("command palette", () => { await input(page).fill("weight"); await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible(); }); + + test("on a phone: a hit from the record fits the width", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockRecordSearch(page); + await page.goto("/"); + await shellReady(page); + await page.locator('[data-slot="command-palette-trigger"]').click(); + await input(page).fill("shoulder"); + const hit = page.locator('[data-entry="record:visit:e2e-visit"]'); + await expect(hit).toBeVisible(); + const box = await hit.boundingBox(); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390); + const overflow = await page + .locator('[data-slot="command-palette-results"]') + .evaluate((el) => el.scrollWidth - el.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + await hit.click(); + await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/); + }); }); diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts index 6ef0f427b..c290471b5 100644 --- a/e2e/day-doors.spec.ts +++ b/e2e/day-doors.spec.ts @@ -84,12 +84,9 @@ test.describe("every chart drawn in days opens its days", () => { .locator('[data-slot="chart-plot"][data-day-links="true"]') .first(); await expect(plot).toBeVisible(); - // Under the chart: the caption and the row of day dots, which draws the + // Under the chart: the row of day dots, which draws the // days the index names, one of them ringed as notable. const rug = page.locator('[data-slot="day-rug"]').first(); - await expect( - page.locator('[data-slot="chart-day-caption"]').first(), - ).toBeVisible(); await expect(rug.locator("[data-day]").first()).toBeAttached(); await expect(rug.locator('[data-notable="true"]')).toHaveCount(1); diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 3b99bacd5..0aae88187 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -275,6 +275,25 @@ test.describe("the day view", () => { ), ) .toBe(true); + // On a phone the calendar is a sheet over the day's own sheet. Focus + // arrives before Radix has handed it the Escape key: in between, the day + // underneath still holds it. The handover shows as the day's layer + // turning inert (`pointer-events: none`) once the calendar is the top + // layer; wait for that, not for a fixed time. The docked column is no + // dialog and has no such handover. + if (!desktop) + await expect + .poll(() => + panel(page).evaluate( + (el) => + new Promise((resolve) => + requestAnimationFrame(() => + setTimeout(() => resolve(getComputedStyle(el).pointerEvents)), + ), + ), + ), + ) + .toBe("none"); await page.keyboard.press("Escape"); await expect(picker).toHaveCount(0); await expect(panel(page)).toBeVisible(); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 8e5a8f76d..a186b6996 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -232,6 +232,45 @@ test.describe("document vault", () => { await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); }); + test("a PDF opens in a new browser tab from the fetched bytes", async ({ + page, + }) => { + // Headless Chromium has no PDF viewer and turns a PDF tab into a + // download, so the tab itself is not observable here. Record what the + // control hands to `window.open` instead, and read the bytes behind it. + await page.addInitScript(() => { + const calls: unknown[][] = []; + (window as unknown as { __opened: unknown[][] }).__opened = calls; + window.open = (...args: unknown[]) => { + calls.push(args); + return null; + }; + }); + await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`); + await openButton(page, "MRT Knie").click(); + const sheet = page.getByRole("dialog", { name: "MRT Knie" }); + await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 }); + const open = sheet.locator('[data-slot="document-open-in-tab"]'); + // Enabled once the preview's bytes are in hand. + await expect(open).toBeEnabled(); + if (process.env.E2E_SHOT_DIR) { + await page.screenshot({ + path: `${process.env.E2E_SHOT_DIR}/open-in-tab-${test.info().project.name}.png`, + }); + } + await open.click(); + const opened = await page.evaluate( + () => (window as unknown as { __opened: string[][] }).__opened, + ); + // The object URL the preview already shows, not the serve route: a bare + // navigation would carry no record-session assertion. + const previewSrc = await sheet.locator("iframe").getAttribute("src"); + expect(previewSrc).toMatch(/^blob:/); + expect(opened).toEqual([[previewSrc, "_blank", "noopener"]]); + // The sheet stays where it was. + await expect(sheet).toBeVisible(); + }); + test("doctor flow B: the condition filter reaches the MRT report", async ({ page, }) => { diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 07cf90bc4..39b4d40fb 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -67,6 +67,69 @@ test("is off by default: no navigation entry, a deep link meets the notice", asy await expect(page.locator(navEntry)).toHaveCount(0); }); +test("opens on three months when the URL names no zoom", async ({ page }) => { + await mockTimeline(page, "ready", TODAY); + await page.goto("/settings/modules"); + await setTimelineModule(page, true); + await page.goto("/timeline"); + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), + ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); + // The next test starts from the module switched off. + await setTimelineModule(page, false); +}); + +test("only old data opens on everything, and an empty window offers it", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY, { onlyOld: true }); + await page.goto("/timeline"); + // The default three months hold nothing: the page opens on everything, + // without writing it into the URL. + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="all"]'), + ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); + await expect(page).toHaveURL(/\/timeline$/); + expect(log.timelineQueries.map((q) => q.get("zoom"))).toEqual( + expect.arrayContaining(["quarter", "all"]), + ); + + // Three months asked for by name stay three months, and offer the rest. + await page.goto("/timeline?zoom=quarter"); + const empty = page.locator('[data-slot="timeline-window-empty"]'); + await expect(empty).toBeVisible({ timeout: 20_000 }); + await page.locator('[data-slot="timeline-show-everything"]').click(); + await expect(page).toHaveURL(/zoom=all/); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); + + // The next test starts from the module switched off. + await setTimelineModule(page, false); +}); + +test("a young record opens on three months starting at its first entry", async ({ + page, +}) => { + await setTimelineModuleOn(page); + // The record starts ten days ago: the window spans the four-week minimum. + const day = (offset: number) => { + const [y, m, d] = TODAY.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d + offset)).toISOString().slice(0, 10); + }; + await mockTimeline(page, "full", TODAY, { dataFrom: day(-10) }); + await page.goto("/timeline"); + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), + ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toHaveAttribute( + "data-window-from", + day(-27), + ); + // The next test starts from the module switched off. + await setTimelineModule(page, false); +}); + test("switching it on opens the readiness inventory, which leads to the timeline", async ({ page, }) => { @@ -113,7 +176,7 @@ test("the timeline hands the selected day to the day view through ?day=", async }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); const chart = page.locator('[data-slot="timeline-chart"]'); await expect(chart).toBeVisible({ timeout: 20_000 }); @@ -196,7 +259,7 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]'); await expect(sys).toBeVisible({ timeout: 20_000 }); await expect( @@ -346,7 +409,7 @@ test("each mean's dot sits on the middle of its text and never wraps apart from // A narrow chart: the selection bar's means wrap between units only. await page.setViewportSize({ width: 800, height: 900 }); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({ timeout: 20_000, }); @@ -360,7 +423,7 @@ test("each mean's dot sits on the middle of its text and never wraps apart from // The phone chronicle's means follow the same rule. await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); await check("timeline-chronicle-means"); await testInfo.attach("chronicle-means-390", { body: await page.screenshot(), @@ -373,7 +436,7 @@ test("one medication is one row: its doses cut the bar, its pause is a gap in it }) => { await setTimelineModuleOn(page); await mockTimeline(page, "full", TODAY); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); const lane = page.locator('svg [data-lane="medications"]'); await expect(lane).toBeVisible({ timeout: 20_000 }); // The medication's own span is not drawn beside its course. @@ -423,7 +486,7 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); const chart = page.locator('[data-slot="timeline-chart"]'); await expect(chart).toBeVisible({ timeout: 20_000 }); const desktop = page.locator('[data-slot="timeline-desktop"]'); @@ -495,12 +558,13 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy await popover.locator('[data-slot="timeline-range-apply"]').click(); await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`)); - // A parameter it cannot read opens the whole record. + // A parameter it cannot read opens the same default as no zoom: this + // record has entries in the last three months, so three months. await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01"); - await expect(zoom("all")).toHaveAttribute("aria-checked", "true", { + await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", { timeout: 20_000, }); - await expect(desktop).toHaveAttribute("data-zoom", "all"); + await expect(desktop).toHaveAttribute("data-zoom", "quarter"); }); async function setTimelineModuleOn(page: Page) { @@ -555,7 +619,7 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn await setTimelineModuleOn(page); await mockTimeline(page, "full", TODAY); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); const chronicle = page.locator('[data-slot="timeline-chronicle"]'); await expect(chronicle).toBeVisible({ timeout: 20_000 }); @@ -596,7 +660,7 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn expect((await chronicle.boundingBox())!.y).toBe(listTop); await sheet.locator('[data-slot="timeline-range-apply"]').click(); await expect(page).toHaveURL(/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}/); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); await expect(chronicle).toBeVisible({ timeout: 20_000 }); await page @@ -703,7 +767,7 @@ test("a value point reads on its own, by pointer and by keyboard", async ({ await setTimelineModuleOn(page); await mockTimeline(page, "full", TODAY); await page.setViewportSize({ width: 1440, height: 900 }); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); const targets = page.locator('[data-slot="timeline-point-target"]'); await expect(targets.first()).toBeAttached({ timeout: 20_000 }); @@ -868,7 +932,7 @@ test("the day's strip stays at the right edge, opens and closes the day, and nev for (const width of [1280, 1440, 1920]) { await page.setViewportSize({ width, height: 900 }); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); await expect( page.locator('[data-slot="timeline-chart"] > svg'), ).toBeVisible({ timeout: 20_000 }); @@ -922,14 +986,15 @@ test("the day's strip stays at the right edge, opens and closes the day, and nev await expect(strip).toHaveAttribute("data-state", "closed"); await expect(toggle).toBeFocused(); expect(await where()).toEqual(closed); - await expect(page).toHaveURL(/\/timeline$/); + // Closing the day takes `?day=` out and leaves the zoom it opened with. + await expect(page).toHaveURL(/\/timeline\?zoom=all$/); } // Both themes, open at 1440. await page.setViewportSize({ width: 1440, height: 900 }); for (const colorScheme of ["light", "dark"] as const) { await page.emulateMedia({ colorScheme }); - await page.goto("/timeline"); + await page.goto("/timeline?zoom=all"); await toggle.click(); await expect(panel).toHaveAttribute("data-shell", "docked"); await testInfo.attach(`day-strip-1440-${colorScheme}`, { diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index c99ebda10..512b90c8f 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -822,11 +822,19 @@ export interface TimelineMockLog { /** * Answer the timeline routes from the `full` or `ready` record. Returns a * log of what the page asked for and posted, for assertions. + * + * `onlyOld`: the record's entries all lie before the last year, so a + * quarter or year window answers empty (with the record's `dataFrom`) and + * only everything, or a chosen range, carries them. + * + * `dataFrom`: the record's first entry as the answers name it, to stand in + * for a young record. */ export async function mockTimeline( page: Page, state: "full" | "ready", today: string, + options: { onlyOld?: boolean; dataFrom?: string } = {}, ): Promise { const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => @@ -843,7 +851,19 @@ export async function mockTimeline( const from = url.searchParams.get("from"); const to = url.searchParams.get("to"); const bucket = bucketFor(zoom, from, to); - const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01"; + const dataFrom = + options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01"); + if (options.onlyOld && (zoom === "quarter" || zoom === "year")) { + return json(route, 200, { + ...body, + zoom, + bucket, + range: { from, to, dataFrom }, + lanes: body.lanes.map((lane) => ({ ...lane, items: [] })), + standing: [], + series: values.map((key) => ({ key, unit: null, points: [] })), + }); + } // A chosen range answers its own window, as the server does. const chosen = zoom === "range" && from && to; const all = @@ -854,7 +874,11 @@ export async function mockTimeline( ...body, zoom, bucket, - ...(chosen ? { range: { from, to, dataFrom } } : {}), + ...(chosen + ? { range: { from, to, dataFrom } } + : options.dataFrom + ? { range: { ...body.range, dataFrom } } + : {}), // Any other value the menu offers answers a flat line of its own. series: values.map( (key) => diff --git a/messages/de.json b/messages/de.json index 8395f39a5..118df8494 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Im Krankheitstagebuch erfasst: {count} Symptome", "bodyNotLogged": "Für diesen Tag ist im Krankheitstagebuch nichts erfasst", "openIllness": "Öffnen" - } + }, + "sourceNotification": "Benachrichtigung" }, "medications": { "title": "Medikamente", @@ -1322,6 +1323,14 @@ "disabledToast": "Einnahmen werden nicht erfasst", "toggleFailed": "Einnahme-Erfassung konnte nicht geändert werden." }, + "autoMarkTaken": { + "label": "Einnahmen automatisch erfassen", + "helper": "HealthLog erfasst jede geplante Dosis zu ihrer Uhrzeit als eingenommen. Du kannst eine Dosis weiterhin selbst ändern oder überspringen. Erinnerungen entfallen.", + "cardHint": "Wird automatisch erfasst", + "enabledToast": "Einnahmen werden jetzt automatisch erfasst", + "disabledToast": "Einnahmen werden nicht mehr automatisch erfasst", + "toggleFailed": "Automatische Erfassung konnte nicht geändert werden." + }, "withoutNotification": "Ohne Benachrichtigung", "pausedSince": "Pausiert seit", "takeNow": "Jetzt einnehmen", @@ -1778,6 +1787,7 @@ "sourceImport": "Import", "sourceAppleHealth": "Apple Health", "intakeSourceVia": "über {origin}", + "intakeRecordedAutomatically": "Automatisch erfasst", "intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst", "intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.", "intakeHistoryEmptyAction": "Zur Tagesübersicht", @@ -2843,7 +2853,7 @@ "coach": { "refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen: Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", "refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen. Formuliere deine Frage bitte in diesem Rahmen neu.", - "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", "errorDemo": "In der Demo antwortet der Coach nicht.", "errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter. Verbinde deinen eigenen Schlüssel oder ein lokales Modell.", @@ -2999,7 +3009,7 @@ "settingsSaved": "Gespeichert", "settingsCancel": "Abbrechen", "dailyLimitTitle": "Heute-Limit erreicht", - "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", + "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", "cluster": { @@ -5325,7 +5335,8 @@ "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.", "remindersHeading": "Erinnerungen", "matrixLink": "Benachrichtigungen je Kanal", - "channelsLink": "Zustellkanäle" + "channelsLink": "Zustellkanäle", + "displayHeading": "Auf dem Handy" }, "layout": { "title": "Layout", @@ -6830,6 +6841,7 @@ "nocreds": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.", "token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", + "issuer": "Die Anmeldeantwort kam nicht vom Autorisierungsserver von Oura, deshalb wurde die Verbindung zu deiner Sicherheit abgebrochen. Bitte starte die Verbindung erneut. Wenn das wieder passiert, sag deinem Administrator Bescheid.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." }, "integrationSetupGuide": "Einrichtungsanleitung", @@ -7237,7 +7249,7 @@ "typeCodex": "ChatGPT-Anmeldung (persönlich)", "typeLocal": "Lokales Modell (persönlich)", "typeGateway": "OpenAI-kompatibles Gateway (persönlich)", - "spendToday": "Heute ({date}): {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers.", + "spendToday": "Heute, jeweils am eigenen Kalendertag jedes Kontos: {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers (Konten mit Verbrauch: {accounts}).", "statusPaused": "Pausiert", "pausedHint": "Nach wiederholten Fehlern für {count} Nutzer pausiert bis {at}. Danach wird er automatisch wieder versucht.", "modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet." @@ -8459,6 +8471,25 @@ "changedRead": "Du kannst die Gesundheitsakte der Person, die du betreut hast, jetzt ansehen, aber nicht ändern.", "changedManage": "Du kannst die Gesundheitsakte der Person, die du betreut hast, verwalten." } + }, + "links": { + "open": "In HealthLog öffnen" + }, + "quickActions": { + "moodGood": "Gut", + "moodOkay": "Okay", + "moodBad": "Schlecht" + }, + "content": { + "title": "Gesundheitswerte in Benachrichtigungen", + "description": "Ob eine Benachrichtigung Werte wie deinen Morgenscore zeigen darf.", + "detail": "Aus: Eine Benachrichtigung sagt nur, worum es geht, der Sperrbildschirm zeigt keine Gesundheitswerte. An: Sie nennt auch die Werte, etwa den Morgenscore im Überblick. Ein Tipp auf die Benachrichtigung öffnet in beiden Fällen die passende Stelle in der App.", + "toggleAria": "Gesundheitswerte in Benachrichtigungen zeigen", + "statusDetailed": "Mit Werten", + "statusGeneric": "Ohne Werte", + "detailedToast": "Benachrichtigungen zeigen jetzt Gesundheitswerte.", + "genericToast": "Benachrichtigungen zeigen keine Gesundheitswerte mehr.", + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." } }, "i18n": { @@ -10858,6 +10889,7 @@ "saveError": "Die Änderung konnte nicht gespeichert werden.", "deleteError": "Das Dokument konnte nicht gelöscht werden.", "download": "Herunterladen", + "openInNewTab": "In neuem Tab öffnen", "delete": "Löschen", "titleLabel": "Titel", "titlePlaceholder": "Titel hinzufügen", @@ -11261,7 +11293,13 @@ }, "push": { "title": "Dein Morgenüberblick", - "bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch." + "bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch.", + "titleScore": "Morgenscore {score}", + "band": { + "green": "Heute sieht es stabil aus.", + "yellow": "Heute lohnt sich ein Blick auf ein paar Dinge.", + "red": "Heute brauchen einige Bereiche Aufmerksamkeit." + } }, "item": { "doseWindow": { @@ -12226,7 +12264,6 @@ "notShared": "Einige Bereiche sind für dich nicht freigegeben.", "inTimeline": "In der Zeitleiste", "askCoach": "Im Coach nach diesem Tag fragen", - "chartHint": "Klick auf einen Punkt öffnet den Tag.", "loadFailed": "Dieser Tag konnte nicht geladen werden.", "dayOf": "Tag {n} von {m}", "dayN": "Tag {n}", @@ -12248,16 +12285,10 @@ "captureShort": "Erfassen", "inTimelineShort": "Zeitleiste", "askCoachPrompt": "Was war am {date}?", - "chartHintTouch": "Antippen zeigt den Wert.", - "chartHintBar": "Klick auf einen Balken öffnet den Tag.", - "chartHintTouchBar": "Antippen zeigt die Werte.", - "chartHintCell": "Klick auf einen Tag öffnet ihn.", - "chartHintTouchCell": "Antippen zeigt den Wert des Tages.", "coachChips": "Tage in dieser Antwort", "description": "Alles, was deine Einträge für diesen Tag enthalten.", "openDocument": "Dokument öffnen", "previousReading": "Vorher, {date}", - "rugLegend": "Punkte unter der Achse markieren Tage mit Einträgen, ein Ring einen auffälligen Tag.", "sheetExpand": "Auf volle Höhe aufziehen", "sheetShrink": "Verkleinern", "valuesLegend": "Punkt für diesen Tag, Strich für die 30 Tage davor", @@ -12560,8 +12591,8 @@ }, "palette": { "title": "Suche", - "description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.", - "placeholder": "Seiten, Einstellungen, Module suchen…", + "description": "Finde eine Seite, eine Einstellung, ein Modul, eine Aktion oder einen Eintrag in deinen Daten.", + "placeholder": "Seiten, Einstellungen, deine Einträge suchen…", "results": "Treffer", "noResults": "Nichts gefunden für „{query}“.", "moduleOn": "An", @@ -12575,7 +12606,8 @@ "insights": "Insights", "settings": "Einstellungen", "modules": "Module", - "actions": "Aktionen" + "actions": "Aktionen", + "records": "In deinen Einträgen" }, "actions": { "capture": "Neuer Eintrag", @@ -12583,6 +12615,28 @@ "backup": "Backup erstellen", "today": "Heute öffnen", "shortcuts": "Tastenkürzel zeigen" + }, + "records": { + "loading": "Deine Einträge werden durchsucht…", + "empty": "Nichts in deinen Einträgen für „{query}“.", + "error": "Deine Einträge konnten nicht durchsucht werden. Versuch es gleich noch einmal.", + "truncated": "Nur die neuesten Einträge wurden durchsucht.", + "kind": { + "visit": "Arztbesuch", + "condition": "Erkrankung", + "document": "Dokument", + "vaccination": "Impfung", + "lab": "Laborwert", + "medication": "Medikament", + "symptom": "Symptom", + "workout": "Training", + "note": "Notiz" + }, + "source": { + "mood": "Stimmungsnotiz", + "measurement": "Notiz zu einem Messwert", + "illness": "Krankheitstagebuch" + } } }, "shortcuts": { diff --git a/messages/en.json b/messages/en.json index 164ca09ff..7191e99c1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Logged in the illness journal: {count} symptoms", "bodyNotLogged": "Nothing is logged in the illness journal for this day", "openIllness": "Open" - } + }, + "sourceNotification": "Notification" }, "medications": { "title": "Medications", @@ -1322,6 +1323,14 @@ "disabledToast": "Intake tracking off", "toggleFailed": "Could not change intake tracking." }, + "autoMarkTaken": { + "label": "Record doses automatically", + "helper": "HealthLog records each scheduled dose as taken at its time. You can still change or skip a dose by hand. No reminders are sent.", + "cardHint": "Recorded automatically", + "enabledToast": "Doses are now recorded automatically", + "disabledToast": "Doses are no longer recorded automatically", + "toggleFailed": "Could not change automatic recording." + }, "withoutNotification": "Without notification", "pausedSince": "Paused since", "takeNow": "Take now", @@ -1778,6 +1787,7 @@ "sourceImport": "Import", "sourceAppleHealth": "Apple Health", "intakeSourceVia": "via {origin}", + "intakeRecordedAutomatically": "Recorded automatically", "intakeHistoryEmptyTitle": "No intakes recorded yet", "intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.", "intakeHistoryEmptyAction": "Open daily intake", @@ -2843,7 +2853,7 @@ "coach": { "refusalOutOfScope": "I can only help with the health metrics in your log: measurements, medications, mood, trends. I cannot answer questions outside that scope.", "refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.", - "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.", + "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight in your time zone.", "errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.", "errorDemo": "The Coach doesn't answer in the demo.", "errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that. Connect your own key or a local model.", @@ -2999,7 +3009,7 @@ "settingsSaved": "Saved", "settingsCancel": "Cancel", "dailyLimitTitle": "Daily limit reached", - "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.", + "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight in your time zone.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", "cluster": { @@ -5325,7 +5335,8 @@ "subtitle": "Choose which reminders you receive and where they're delivered.", "remindersHeading": "Reminders", "matrixLink": "Per-channel notifications", - "channelsLink": "Delivery channels" + "channelsLink": "Delivery channels", + "displayHeading": "On your phone" }, "layout": { "title": "Layout", @@ -6830,6 +6841,7 @@ "nocreds": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.", "token": "The token exchange with Oura failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", + "issuer": "The sign-in answer did not come from Oura's own authorization server, so the connection was stopped for your safety. Please start the connection again. If this keeps happening, tell your administrator.", "generic": "Unknown error while connecting. Please start the connection again." }, "integrationSetupGuide": "Setup guide", @@ -7237,7 +7249,7 @@ "typeCodex": "ChatGPT sign-in (personal)", "typeLocal": "Local model (personal)", "typeGateway": "OpenAI-compatible gateway (personal)", - "spendToday": "Today ({date}): {total} tokens, {operator} of them on the operator's own key.", + "spendToday": "Today, on each account's own calendar day: {total} tokens, {operator} of them on the operator's own key (accounts with spend: {accounts}).", "statusPaused": "Paused", "pausedHint": "Paused after repeated failures for {count} users until {at}. It is tried again automatically then.", "modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings." @@ -8459,6 +8471,25 @@ "changedRead": "You can now view the health record of the person you looked after, but not change it.", "changedManage": "You can manage the health record of the person you looked after." } + }, + "links": { + "open": "Open in HealthLog" + }, + "quickActions": { + "moodGood": "Good", + "moodOkay": "Okay", + "moodBad": "Bad" + }, + "content": { + "title": "Health figures in notifications", + "description": "Whether a notification may show values such as your morning score.", + "detail": "Off, a notification only says what it is about, so a lock screen shows no health figures. On, it adds the figures, for example the morning score in the briefing. Tapping a notification opens the right place in the app either way.", + "toggleAria": "Show health figures in notifications", + "statusDetailed": "Figures shown", + "statusGeneric": "Without figures", + "detailedToast": "Notifications now include health figures.", + "genericToast": "Notifications no longer include health figures.", + "saveError": "Could not save. Please try again later." } }, "i18n": { @@ -10858,6 +10889,7 @@ "saveError": "Couldn't save the change.", "deleteError": "Couldn't delete the document.", "download": "Download", + "openInNewTab": "Open in new tab", "delete": "Delete", "titleLabel": "Title", "titlePlaceholder": "Add a title", @@ -11261,7 +11293,13 @@ }, "push": { "title": "Your morning briefing", - "bodyProvisional": "{line}\nLast night's sleep isn't in yet." + "bodyProvisional": "{line}\nLast night's sleep isn't in yet.", + "titleScore": "Morning score {score}", + "band": { + "green": "Looking steady today.", + "yellow": "A few things are worth a look today.", + "red": "Some areas need attention today." + } }, "item": { "doseWindow": { @@ -12226,7 +12264,6 @@ "notShared": "Some areas are not shared with you.", "inTimeline": "In the timeline", "askCoach": "Ask the Coach about this day", - "chartHint": "Click a point to open its day.", "loadFailed": "This day could not be loaded.", "dayOf": "Day {n} of {m}", "dayN": "Day {n}", @@ -12248,16 +12285,10 @@ "captureShort": "Log", "inTimelineShort": "Timeline", "askCoachPrompt": "What happened on {date}?", - "chartHintTouch": "Tap a point to see its value.", - "chartHintBar": "Click a bar to open its day.", - "chartHintTouchBar": "Tap a bar to see its values.", - "chartHintCell": "Click a day to open it.", - "chartHintTouchCell": "Tap a day to see its value.", "coachChips": "Days in this answer", "description": "Everything your records hold for this day.", "openDocument": "Open document", "previousReading": "Before, {date}", - "rugLegend": "Dots under the axis mark days with entries, a ring marks a notable day.", "sheetExpand": "Expand to full height", "sheetShrink": "Shrink", "valuesLegend": "Dot for this day, bar for the 30 days before", @@ -12560,8 +12591,8 @@ }, "palette": { "title": "Search", - "description": "Find a page, a setting, a module or an action.", - "placeholder": "Search pages, settings, modules…", + "description": "Find a page, a setting, a module, an action or an entry in your records.", + "placeholder": "Search pages, settings, your records…", "results": "Results", "noResults": "Nothing found for “{query}”.", "moduleOn": "On", @@ -12575,7 +12606,8 @@ "insights": "Insights", "settings": "Settings", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "In your records" }, "actions": { "capture": "New entry", @@ -12583,6 +12615,28 @@ "backup": "Create a backup", "today": "Open today", "shortcuts": "Show keyboard shortcuts" + }, + "records": { + "loading": "Searching your records…", + "empty": "Nothing in your records for “{query}”.", + "error": "Your records could not be searched. Try again in a moment.", + "truncated": "Only the most recent entries were searched.", + "kind": { + "visit": "Visit", + "condition": "Condition", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Lab result", + "medication": "Medication", + "symptom": "Symptom", + "workout": "Workout", + "note": "Note" + }, + "source": { + "mood": "Mood note", + "measurement": "Note on a reading", + "illness": "Illness diary" + } } }, "shortcuts": { diff --git a/messages/es.json b/messages/es.json index 85feaee82..7ce217112 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Registrado en el diario de enfermedad: {count} síntomas", "bodyNotLogged": "No hay nada registrado en el diario de enfermedad para este día", "openIllness": "Abrir" - } + }, + "sourceNotification": "Notificación" }, "medications": { "title": "Medicamentos", @@ -1322,6 +1323,14 @@ "disabledToast": "Registro de tomas desactivado", "toggleFailed": "No se pudo cambiar el registro de tomas." }, + "autoMarkTaken": { + "label": "Registrar las tomas automáticamente", + "helper": "HealthLog registra cada dosis programada como tomada a su hora. Puedes seguir cambiando u omitiendo una dosis a mano. No se envían recordatorios.", + "cardHint": "Se registra automáticamente", + "enabledToast": "Las tomas se registran ahora automáticamente", + "disabledToast": "Las tomas ya no se registran automáticamente", + "toggleFailed": "No se pudo cambiar el registro automático." + }, "withoutNotification": "Sin notificación", "pausedSince": "En pausa desde", "takeNow": "Tomar ahora", @@ -1778,6 +1787,7 @@ "sourceImport": "Importación", "sourceAppleHealth": "Apple Health", "intakeSourceVia": "vía {origin}", + "intakeRecordedAutomatically": "Registrada automáticamente", "intakeHistoryEmptyTitle": "Aún no hay tomas", "intakeHistoryEmptyDescription": "En cuanto registres una toma, aparecerá aquí con la hora y la fuente.", "intakeHistoryEmptyAction": "Registrar toma", @@ -2843,7 +2853,7 @@ "coach": { "refusalOutOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. Preguntas fuera de ese ámbito no las respondo.", "refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud. Reformula tu pregunta dentro de ese marco.", - "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).", + "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche en tu zona horaria.", "errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.", "errorDemo": "En la demo, el Coach no responde.", "errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.", @@ -2999,7 +3009,7 @@ "settingsSaved": "Guardado", "settingsCancel": "Cancelar", "dailyLimitTitle": "Límite diario alcanzado", - "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", + "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche en tu zona horaria.", "providerRateLimitTitle": "Proveedor con límite de tasa", "providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.", "cluster": { @@ -5325,7 +5335,8 @@ "subtitle": "Elige qué recordatorios recibes y dónde se entregan.", "remindersHeading": "Recordatorios", "matrixLink": "Notificaciones por canal", - "channelsLink": "Canales de entrega" + "channelsLink": "Canales de entrega", + "displayHeading": "En tu teléfono" }, "layout": { "title": "Diseño", @@ -6830,6 +6841,7 @@ "nocreds": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.", "token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", + "issuer": "La respuesta de inicio de sesión no procedía del servidor de autorización de Oura, así que la conexión se detuvo por tu seguridad. Vuelve a iniciar la conexión. Si sigue ocurriendo, avisa a tu administrador.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." }, "integrationSetupGuide": "Guía de configuración", @@ -7237,7 +7249,7 @@ "typeCodex": "Inicio de sesión de ChatGPT (personal)", "typeLocal": "Modelo local (personal)", "typeGateway": "Pasarela compatible con OpenAI (personal)", - "spendToday": "Hoy ({date}): {total} tokens, {operator} de ellos en la clave del operador.", + "spendToday": "Hoy, en el día natural de cada cuenta: {total} tokens, {operator} de ellos en la clave del operador (cuentas con consumo: {accounts}).", "statusPaused": "En pausa", "pausedHint": "En pausa tras fallos repetidos para {count} usuarios hasta {at}. Después se vuelve a intentar automáticamente.", "modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca." @@ -8459,6 +8471,25 @@ "changedRead": "Ahora puedes ver el historial de salud de la persona a la que cuidabas, pero no modificarlo.", "changedManage": "Puedes gestionar el historial de salud de la persona a la que cuidabas." } + }, + "links": { + "open": "Abrir en HealthLog" + }, + "quickActions": { + "moodGood": "Bien", + "moodOkay": "Normal", + "moodBad": "Mal" + }, + "content": { + "title": "Valores de salud en las notificaciones", + "description": "Si una notificación puede mostrar valores como tu puntuación de la mañana.", + "detail": "Desactivado, una notificación solo dice de qué trata, así que la pantalla de bloqueo no muestra valores de salud. Activado, añade los valores, por ejemplo la puntuación de la mañana en el resumen. Al tocar una notificación se abre el lugar adecuado de la app en ambos casos.", + "toggleAria": "Mostrar valores de salud en las notificaciones", + "statusDetailed": "Con valores", + "statusGeneric": "Sin valores", + "detailedToast": "Las notificaciones ahora incluyen valores de salud.", + "genericToast": "Las notificaciones ya no incluyen valores de salud.", + "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde." } }, "i18n": { @@ -10858,6 +10889,7 @@ "saveError": "No se pudo guardar el cambio.", "deleteError": "No se pudo eliminar el documento.", "download": "Descargar", + "openInNewTab": "Abrir en una pestaña nueva", "delete": "Eliminar", "titleLabel": "Título", "titlePlaceholder": "Añadir un título", @@ -11261,7 +11293,13 @@ }, "push": { "title": "Tu resumen matinal", - "bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible." + "bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible.", + "titleScore": "Puntuación de la mañana {score}", + "band": { + "green": "Hoy todo parece estable.", + "yellow": "Hoy vale la pena revisar algunas cosas.", + "red": "Hoy algunas áreas necesitan atención." + } }, "item": { "doseWindow": { @@ -12226,7 +12264,6 @@ "notShared": "Algunas áreas no están compartidas contigo.", "inTimeline": "En la cronología", "askCoach": "Preguntar al Coach por este día", - "chartHint": "Haz clic en un punto para abrir su día.", "loadFailed": "No se pudo cargar este día.", "dayOf": "Día {n} de {m}", "dayN": "Día {n}", @@ -12248,16 +12285,10 @@ "captureShort": "Registrar", "inTimelineShort": "Cronología", "askCoachPrompt": "¿Qué pasó el {date}?", - "chartHintTouch": "Toca un punto para ver su valor.", - "chartHintBar": "Haz clic en una barra para abrir su día.", - "chartHintTouchBar": "Toca una barra para ver sus valores.", - "chartHintCell": "Haz clic en un día para abrirlo.", - "chartHintTouchCell": "Toca un día para ver su valor.", "coachChips": "Días en esta respuesta", "description": "Todo lo que tus registros contienen para este día.", "openDocument": "Abrir documento", "previousReading": "Antes, {date}", - "rugLegend": "Los puntos bajo el eje marcan días con entradas, y un anillo, un día destacado.", "sheetExpand": "Ampliar a toda la altura", "sheetShrink": "Reducir", "valuesLegend": "Punto para este día, barra para los 30 días anteriores", @@ -12560,8 +12591,8 @@ }, "palette": { "title": "Buscar", - "description": "Encuentra una página, un ajuste, un módulo o una acción.", - "placeholder": "Buscar páginas, ajustes, módulos…", + "description": "Encuentra una página, un ajuste, un módulo, una acción o una entrada de tus registros.", + "placeholder": "Buscar páginas, ajustes, tus registros…", "results": "Resultados", "noResults": "No hay resultados para «{query}».", "moduleOn": "Activado", @@ -12575,7 +12606,8 @@ "insights": "Insights", "settings": "Ajustes", "modules": "Módulos", - "actions": "Acciones" + "actions": "Acciones", + "records": "En tus registros" }, "actions": { "capture": "Nueva entrada", @@ -12583,6 +12615,28 @@ "backup": "Crear una copia de seguridad", "today": "Abrir hoy", "shortcuts": "Mostrar atajos de teclado" + }, + "records": { + "loading": "Buscando en tus registros…", + "empty": "Nada en tus registros para «{query}».", + "error": "No se pudo buscar en tus registros. Inténtalo de nuevo en un momento.", + "truncated": "Solo se buscaron las entradas más recientes.", + "kind": { + "visit": "Consulta", + "condition": "Enfermedad", + "document": "Documento", + "vaccination": "Vacuna", + "lab": "Resultado de laboratorio", + "medication": "Medicamento", + "symptom": "Síntoma", + "workout": "Entrenamiento", + "note": "Nota" + }, + "source": { + "mood": "Nota de ánimo", + "measurement": "Nota de una medición", + "illness": "Diario de enfermedad" + } } }, "shortcuts": { diff --git a/messages/fr.json b/messages/fr.json index e84f55fb3..5181f03ba 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Consigné dans le journal de maladie : {count} symptômes", "bodyNotLogged": "Rien n'est consigné dans le journal de maladie pour cette journée", "openIllness": "Ouvrir" - } + }, + "sourceNotification": "Notification" }, "medications": { "title": "Médicaments", @@ -1322,6 +1323,14 @@ "disabledToast": "Suivi des prises désactivé", "toggleFailed": "Impossible de modifier le suivi des prises." }, + "autoMarkTaken": { + "label": "Enregistrer les prises automatiquement", + "helper": "HealthLog enregistre chaque dose prévue comme prise à son heure. Vous pouvez toujours modifier ou sauter une dose à la main. Aucun rappel n'est envoyé.", + "cardHint": "Enregistré automatiquement", + "enabledToast": "Les prises sont désormais enregistrées automatiquement", + "disabledToast": "Les prises ne sont plus enregistrées automatiquement", + "toggleFailed": "Impossible de modifier l'enregistrement automatique." + }, "withoutNotification": "Sans notification", "pausedSince": "En pause depuis", "takeNow": "Prendre maintenant", @@ -1778,6 +1787,7 @@ "sourceImport": "Import", "sourceAppleHealth": "Apple Health", "intakeSourceVia": "via {origin}", + "intakeRecordedAutomatically": "Enregistrée automatiquement", "intakeHistoryEmptyTitle": "Aucune prise pour l’instant", "intakeHistoryEmptyDescription": "Dès que tu enregistreras une prise, elle apparaîtra ici avec l’heure et la source.", "intakeHistoryEmptyAction": "Enregistrer une prise", @@ -2843,7 +2853,7 @@ "coach": { "refusalOutOfScope": "Je ne peux aider que pour les données de santé de ton journal : mesures, médicaments, humeur, tendances. Les questions hors de ce périmètre ne sont pas traitées.", "refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé. Reformule ta question dans ce cadre.", - "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).", + "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit dans ton fuseau horaire.", "errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.", "errorDemo": "Dans la démo, le Coach ne répond pas.", "errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.", @@ -2999,7 +3009,7 @@ "settingsSaved": "Enregistré", "settingsCancel": "Annuler", "dailyLimitTitle": "Limite quotidienne atteinte", - "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", + "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit dans ton fuseau horaire.", "providerRateLimitTitle": "Fournisseur limité en débit", "providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.", "cluster": { @@ -5325,7 +5335,8 @@ "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.", "remindersHeading": "Rappels", "matrixLink": "Notifications par canal", - "channelsLink": "Canaux de diffusion" + "channelsLink": "Canaux de diffusion", + "displayHeading": "Sur ton téléphone" }, "layout": { "title": "Disposition", @@ -6830,6 +6841,7 @@ "nocreds": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.", "token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.", "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", + "issuer": "La réponse de connexion ne provenait pas du serveur d'autorisation d'Oura, la connexion a donc été interrompue par sécurité. Veuillez relancer la connexion. Si cela se reproduit, prévenez votre administrateur.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." }, "integrationSetupGuide": "Guide de configuration", @@ -7237,7 +7249,7 @@ "typeCodex": "Connexion ChatGPT (personnelle)", "typeLocal": "Modèle local (personnel)", "typeGateway": "Passerelle compatible OpenAI (personnelle)", - "spendToday": "Aujourd'hui ({date}) : {total} jetons, dont {operator} sur la clé de l'opérateur.", + "spendToday": "Aujourd'hui, selon le jour civil de chaque compte : {total} jetons, dont {operator} sur la clé de l'opérateur (comptes avec consommation : {accounts}).", "statusPaused": "En pause", "pausedHint": "En pause après des échecs répétés pour {count} utilisateurs jusqu’au {at}. Il sera ensuite réessayé automatiquement.", "modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose." @@ -8459,6 +8471,25 @@ "changedRead": "Vous pouvez désormais consulter le dossier de santé de la personne dont vous vous occupiez, mais pas le modifier.", "changedManage": "Vous pouvez gérer le dossier de santé de la personne dont vous vous occupiez." } + }, + "links": { + "open": "Ouvrir dans HealthLog" + }, + "quickActions": { + "moodGood": "Bien", + "moodOkay": "Moyen", + "moodBad": "Mal" + }, + "content": { + "title": "Valeurs de santé dans les notifications", + "description": "Si une notification peut afficher des valeurs comme ton score du matin.", + "detail": "Désactivé, une notification dit seulement de quoi il s'agit, l'écran verrouillé n'affiche donc aucune valeur de santé. Activé, elle ajoute les valeurs, par exemple le score du matin dans le récapitulatif. Toucher une notification ouvre le bon endroit de l'app dans les deux cas.", + "toggleAria": "Afficher les valeurs de santé dans les notifications", + "statusDetailed": "Avec valeurs", + "statusGeneric": "Sans valeurs", + "detailedToast": "Les notifications incluent désormais des valeurs de santé.", + "genericToast": "Les notifications n'incluent plus de valeurs de santé.", + "saveError": "Enregistrement impossible. Réessaie plus tard." } }, "i18n": { @@ -10858,6 +10889,7 @@ "saveError": "Impossible d'enregistrer la modification.", "deleteError": "Impossible de supprimer le document.", "download": "Télécharger", + "openInNewTab": "Ouvrir dans un nouvel onglet", "delete": "Supprimer", "titleLabel": "Titre", "titlePlaceholder": "Ajouter un titre", @@ -11261,7 +11293,13 @@ }, "push": { "title": "Votre résumé du matin", - "bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible." + "bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible.", + "titleScore": "Score du matin {score}", + "band": { + "green": "Tout semble stable aujourd'hui.", + "yellow": "Quelques points méritent un coup d'œil aujourd'hui.", + "red": "Certains domaines demandent de l'attention aujourd'hui." + } }, "item": { "doseWindow": { @@ -12226,7 +12264,6 @@ "notShared": "Certaines rubriques ne sont pas partagées avec vous.", "inTimeline": "Dans la chronologie", "askCoach": "Interroger le Coach sur ce jour", - "chartHint": "Cliquez sur un point pour ouvrir son jour.", "loadFailed": "Impossible de charger ce jour.", "dayOf": "Jour {n} sur {m}", "dayN": "Jour {n}", @@ -12248,16 +12285,10 @@ "captureShort": "Enregistrer", "inTimelineShort": "Chronologie", "askCoachPrompt": "Que s’est-il passé le {date} ?", - "chartHintTouch": "Touchez un point pour voir sa valeur.", - "chartHintBar": "Cliquez sur une barre pour ouvrir son jour.", - "chartHintTouchBar": "Touchez une barre pour voir ses valeurs.", - "chartHintCell": "Cliquez sur un jour pour l’ouvrir.", - "chartHintTouchCell": "Touchez un jour pour voir sa valeur.", "coachChips": "Jours dans cette réponse", "description": "Tout ce que vos entrées contiennent pour ce jour.", "openDocument": "Ouvrir le document", "previousReading": "Avant, {date}", - "rugLegend": "Les points sous l’axe marquent les jours avec entrées, un anneau un jour remarquable.", "sheetExpand": "Agrandir en pleine hauteur", "sheetShrink": "Réduire", "valuesLegend": "Point pour ce jour, barre pour les 30 jours précédents", @@ -12560,8 +12591,8 @@ }, "palette": { "title": "Recherche", - "description": "Trouver une page, un réglage, un module ou une action.", - "placeholder": "Rechercher des pages, réglages, modules…", + "description": "Trouver une page, un réglage, un module, une action ou une entrée de votre dossier.", + "placeholder": "Rechercher des pages, réglages, votre dossier…", "results": "Résultats", "noResults": "Aucun résultat pour « {query} ».", "moduleOn": "Activé", @@ -12575,7 +12606,8 @@ "insights": "Insights", "settings": "Paramètres", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "Dans votre dossier" }, "actions": { "capture": "Nouvelle saisie", @@ -12583,6 +12615,28 @@ "backup": "Créer une sauvegarde", "today": "Ouvrir aujourd’hui", "shortcuts": "Afficher les raccourcis clavier" + }, + "records": { + "loading": "Recherche dans votre dossier…", + "empty": "Rien dans votre dossier pour « {query} ».", + "error": "Votre dossier n’a pas pu être parcouru. Réessayez dans un instant.", + "truncated": "Seules les entrées les plus récentes ont été parcourues.", + "kind": { + "visit": "Consultation", + "condition": "Maladie", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Résultat d’analyse", + "medication": "Médicament", + "symptom": "Symptôme", + "workout": "Entraînement", + "note": "Note" + }, + "source": { + "mood": "Note d’humeur", + "measurement": "Note sur une mesure", + "illness": "Journal de maladie" + } } }, "shortcuts": { diff --git a/messages/it.json b/messages/it.json index 6937f4746..102b09984 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Registrato nel diario di malattia: {count} sintomi", "bodyNotLogged": "Per questa giornata non c'è nulla nel diario di malattia", "openIllness": "Apri" - } + }, + "sourceNotification": "Notifica" }, "medications": { "title": "Farmaci", @@ -1322,6 +1323,14 @@ "disabledToast": "Registrazione assunzioni disattivata", "toggleFailed": "Impossibile modificare la registrazione delle assunzioni." }, + "autoMarkTaken": { + "label": "Registra le assunzioni automaticamente", + "helper": "HealthLog registra ogni dose programmata come assunta al suo orario. Puoi comunque modificare o saltare una dose a mano. Non vengono inviati promemoria.", + "cardHint": "Registrato automaticamente", + "enabledToast": "Le assunzioni ora vengono registrate automaticamente", + "disabledToast": "Le assunzioni non vengono più registrate automaticamente", + "toggleFailed": "Impossibile modificare la registrazione automatica." + }, "withoutNotification": "Senza notifica", "pausedSince": "In pausa dal", "takeNow": "Assumi ora", @@ -1778,6 +1787,7 @@ "sourceImport": "Importazione", "sourceAppleHealth": "Apple Health", "intakeSourceVia": "tramite {origin}", + "intakeRecordedAutomatically": "Registrata automaticamente", "intakeHistoryEmptyTitle": "Ancora nessuna assunzione", "intakeHistoryEmptyDescription": "Appena registrerai un’assunzione, comparirà qui con orario e sorgente.", "intakeHistoryEmptyAction": "Registra un’assunzione", @@ -2843,7 +2853,7 @@ "coach": { "refusalOutOfScope": "Posso aiutarti solo con i dati di salute del tuo log: misurazioni, farmaci, umore, trend. Domande fuori da questo ambito non le rispondo.", "refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute: riformula la domanda in questo perimetro.", - "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).", + "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.", "errorDemo": "Nella demo il Coach non risponde.", "errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.", @@ -2999,7 +3009,7 @@ "settingsSaved": "Salvato", "settingsCancel": "Annulla", "dailyLimitTitle": "Limite giornaliero raggiunto", - "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", + "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte nel tuo fuso orario.", "providerRateLimitTitle": "Provider con rate-limit", "providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.", "cluster": { @@ -5325,7 +5335,8 @@ "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.", "remindersHeading": "Promemoria", "matrixLink": "Notifiche per canale", - "channelsLink": "Canali di recapito" + "channelsLink": "Canali di recapito", + "displayHeading": "Sul telefono" }, "layout": { "title": "Layout", @@ -6830,6 +6841,7 @@ "nocreds": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.", "token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", + "issuer": "La risposta di accesso non proveniva dal server di autorizzazione di Oura, quindi la connessione è stata interrotta per la tua sicurezza. Avvia di nuovo la connessione. Se succede ancora, avvisa il tuo amministratore.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." }, "integrationSetupGuide": "Guida alla configurazione", @@ -7237,7 +7249,7 @@ "typeCodex": "Accesso ChatGPT (personale)", "typeLocal": "Modello locale (personale)", "typeGateway": "Gateway compatibile OpenAI (personale)", - "spendToday": "Oggi ({date}): {total} token, di cui {operator} sulla chiave dell'operatore.", + "spendToday": "Oggi, secondo il giorno di calendario di ciascun account: {total} token, di cui {operator} sulla chiave dell'operatore (account con consumo: {accounts}).", "statusPaused": "In pausa", "pausedHint": "In pausa dopo errori ripetuti per {count} utenti fino al {at}. Poi viene ritentato automaticamente.", "modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre." @@ -8459,6 +8471,25 @@ "changedRead": "Ora puoi consultare la cartella sanitaria della persona di cui ti occupavi, ma non modificarla.", "changedManage": "Puoi gestire la cartella sanitaria della persona di cui ti occupavi." } + }, + "links": { + "open": "Apri in HealthLog" + }, + "quickActions": { + "moodGood": "Bene", + "moodOkay": "Così così", + "moodBad": "Male" + }, + "content": { + "title": "Valori di salute nelle notifiche", + "description": "Se una notifica può mostrare valori come il tuo punteggio del mattino.", + "detail": "Disattivato, una notifica dice solo di cosa si tratta, quindi la schermata di blocco non mostra valori di salute. Attivato, aggiunge i valori, per esempio il punteggio del mattino nel riepilogo. Toccando una notifica si apre il punto giusto dell'app in entrambi i casi.", + "toggleAria": "Mostra i valori di salute nelle notifiche", + "statusDetailed": "Con valori", + "statusGeneric": "Senza valori", + "detailedToast": "Le notifiche ora includono valori di salute.", + "genericToast": "Le notifiche non includono più valori di salute.", + "saveError": "Impossibile salvare. Riprova più tardi." } }, "i18n": { @@ -10858,6 +10889,7 @@ "saveError": "Impossibile salvare la modifica.", "deleteError": "Impossibile eliminare il documento.", "download": "Scarica", + "openInNewTab": "Apri in una nuova scheda", "delete": "Elimina", "titleLabel": "Titolo", "titlePlaceholder": "Aggiungi un titolo", @@ -11261,7 +11293,13 @@ }, "push": { "title": "Il tuo riepilogo mattutino", - "bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile." + "bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile.", + "titleScore": "Punteggio del mattino {score}", + "band": { + "green": "Oggi tutto sembra stabile.", + "yellow": "Oggi vale la pena dare un'occhiata ad alcune cose.", + "red": "Oggi alcune aree richiedono attenzione." + } }, "item": { "doseWindow": { @@ -12226,7 +12264,6 @@ "notShared": "Alcune aree non sono condivise con te.", "inTimeline": "Nella cronologia", "askCoach": "Chiedi al Coach di questo giorno", - "chartHint": "Fai clic su un punto per aprire il suo giorno.", "loadFailed": "Impossibile caricare questo giorno.", "dayOf": "Giorno {n} di {m}", "dayN": "Giorno {n}", @@ -12248,16 +12285,10 @@ "captureShort": "Registra", "inTimelineShort": "Cronologia", "askCoachPrompt": "Cosa è successo il {date}?", - "chartHintTouch": "Tocca un punto per vederne il valore.", - "chartHintBar": "Fai clic su una barra per aprire il suo giorno.", - "chartHintTouchBar": "Tocca una barra per vederne i valori.", - "chartHintCell": "Fai clic su un giorno per aprirlo.", - "chartHintTouchCell": "Tocca un giorno per vederne il valore.", "coachChips": "Giorni in questa risposta", "description": "Tutto ciò che le tue voci contengono per questo giorno.", "openDocument": "Apri documento", "previousReading": "Prima, {date}", - "rugLegend": "I punti sotto l’asse indicano i giorni con voci, un anello un giorno rilevante.", "sheetExpand": "Espandi a tutta altezza", "sheetShrink": "Riduci", "valuesLegend": "Punto per questo giorno, barra per i 30 giorni precedenti", @@ -12560,8 +12591,8 @@ }, "palette": { "title": "Cerca", - "description": "Trova una pagina, un’impostazione, un modulo o un’azione.", - "placeholder": "Cerca pagine, impostazioni, moduli…", + "description": "Trova una pagina, un’impostazione, un modulo, un’azione o una voce dei tuoi dati.", + "placeholder": "Cerca pagine, impostazioni, i tuoi dati…", "results": "Risultati", "noResults": "Nessun risultato per «{query}».", "moduleOn": "Attivo", @@ -12575,7 +12606,8 @@ "insights": "Insights", "settings": "Impostazioni", "modules": "Moduli", - "actions": "Azioni" + "actions": "Azioni", + "records": "Nei tuoi dati" }, "actions": { "capture": "Nuova voce", @@ -12583,6 +12615,28 @@ "backup": "Crea un backup", "today": "Apri oggi", "shortcuts": "Mostra scorciatoie da tastiera" + }, + "records": { + "loading": "Ricerca nei tuoi dati…", + "empty": "Niente nei tuoi dati per «{query}».", + "error": "Non è stato possibile cercare nei tuoi dati. Riprova tra un momento.", + "truncated": "Sono state cercate solo le voci più recenti.", + "kind": { + "visit": "Visita", + "condition": "Malattia", + "document": "Documento", + "vaccination": "Vaccinazione", + "lab": "Esame di laboratorio", + "medication": "Farmaco", + "symptom": "Sintomo", + "workout": "Allenamento", + "note": "Nota" + }, + "source": { + "mood": "Nota sull’umore", + "measurement": "Nota su una misurazione", + "illness": "Diario di malattia" + } } }, "shortcuts": { diff --git a/messages/ko.json b/messages/ko.json index ec79c416d..d05ccb066 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1229,7 +1229,8 @@ "bodyLogged": "아픈 날 일지에 증상 {count}개를 기록했어요", "bodyNotLogged": "이 날은 아픈 날 일지에 기록된 게 없어요", "openIllness": "열기" - } + }, + "sourceNotification": "알림" }, "medications": { "title": "복약", @@ -1322,6 +1323,14 @@ "disabledToast": "복용 기록 꺼짐", "toggleFailed": "복용 기록 설정을 바꾸지 못했어요." }, + "autoMarkTaken": { + "label": "복용 자동 기록", + "helper": "HealthLog가 예정된 각 복용을 해당 시간에 복용한 것으로 기록합니다. 복용은 언제든 직접 변경하거나 건너뛸 수 있습니다. 알림은 보내지 않습니다.", + "cardHint": "자동으로 기록됨", + "enabledToast": "이제 복용이 자동으로 기록됩니다", + "disabledToast": "더 이상 복용이 자동으로 기록되지 않습니다", + "toggleFailed": "자동 기록을 변경하지 못했습니다." + }, "withoutNotification": "알림 없음", "pausedSince": "일시중지 시작", "takeNow": "지금 복용", @@ -1778,6 +1787,7 @@ "sourceImport": "불러오기", "sourceAppleHealth": "Apple Health", "intakeSourceVia": "{origin} 경유", + "intakeRecordedAutomatically": "자동으로 기록됨", "intakeHistoryEmptyTitle": "아직 복용 기록이 없어요", "intakeHistoryEmptyDescription": "일일 복용 페이지에서 용량을 기록하면 이력이 쌓여요.", "intakeHistoryEmptyAction": "일일 복용 열기", @@ -2843,7 +2853,7 @@ "coach": { "refusalOutOfScope": "기록에 있는 건강 지표(측정값, 약, 기분, 추이)만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.", "refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.", - "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.", "errorDemo": "데모에서는 코치가 답하지 않아요.", "errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.", @@ -2999,7 +3009,7 @@ "settingsSaved": "저장됨", "settingsCancel": "취소", "dailyLimitTitle": "하루 한도 도달", - "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", + "dailyLimitBody": "하루 한도에 도달했어요. 사용량은 내 시간대의 자정에 초기화돼요.", "providerRateLimitTitle": "제공자 요청 제한", "providerRateLimitBody": "제공자가 일시적으로 요청을 제한하고 있어요. 5분쯤 뒤에 다시 시도해 주세요.", "cluster": { @@ -5325,7 +5335,8 @@ "subtitle": "받을 알림과 전달될 곳을 골라요.", "remindersHeading": "알림", "matrixLink": "채널별 알림", - "channelsLink": "전송 채널" + "channelsLink": "전송 채널", + "displayHeading": "휴대폰에서" }, "layout": { "title": "화면 구성", @@ -6830,6 +6841,7 @@ "nocreds": "이 서버에는 Oura가 설정돼 있지 않아요. 관리자에게 Oura OAuth 자격 증명을 추가해 달라고 요청하세요.", "token": "Oura와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", + "issuer": "로그인 응답이 Oura의 인증 서버에서 오지 않아서 안전을 위해 연결을 중단했어요. 연결을 다시 시작해 주세요. 계속 발생하면 관리자에게 알려 주세요.", "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." }, "integrationSetupGuide": "설정 가이드", @@ -7955,7 +7967,7 @@ "typeCodex": "ChatGPT 로그인 (개인)", "typeLocal": "로컬 모델 (개인)", "typeGateway": "OpenAI 호환 게이트웨이 (개인)", - "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다.", + "spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).", "statusPaused": "일시 중지됨", "pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.", "modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요." @@ -8459,6 +8471,25 @@ "changedRead": "이제 돌보던 분의 건강 기록을 볼 수 있지만 변경할 수는 없어요.", "changedManage": "돌보던 분의 건강 기록을 관리할 수 있어요." } + }, + "links": { + "open": "HealthLog에서 열기" + }, + "quickActions": { + "moodGood": "좋음", + "moodOkay": "보통", + "moodBad": "나쁨" + }, + "content": { + "title": "알림의 건강 수치", + "description": "알림에 아침 점수 같은 수치를 표시할지 정합니다.", + "detail": "끄면 알림은 무엇에 관한 것인지만 알려 주므로 잠금 화면에 건강 수치가 나타나지 않아요. 켜면 수치도 함께 보여 줘요. 예를 들어 브리핑에 아침 점수가 표시됩니다. 어느 쪽이든 알림을 누르면 앱의 알맞은 곳이 열려요.", + "toggleAria": "알림에 건강 수치 표시", + "statusDetailed": "수치 표시", + "statusGeneric": "수치 없이", + "detailedToast": "이제 알림에 건강 수치가 포함됩니다.", + "genericToast": "이제 알림에 건강 수치가 포함되지 않습니다.", + "saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요." } }, "i18n": { @@ -10858,6 +10889,7 @@ "saveError": "변경 내용을 저장하지 못했어요.", "deleteError": "문서를 삭제하지 못했어요.", "download": "다운로드", + "openInNewTab": "새 탭에서 열기", "delete": "삭제", "titleLabel": "제목", "titlePlaceholder": "제목 입력", @@ -11261,7 +11293,13 @@ }, "push": { "title": "아침 브리핑", - "bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요." + "bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요.", + "titleScore": "아침 점수 {score}", + "band": { + "green": "오늘은 안정적으로 보여요.", + "yellow": "오늘은 몇 가지를 살펴볼 만해요.", + "red": "오늘은 몇몇 영역에 주의가 필요해요." + } }, "item": { "doseWindow": { @@ -12226,7 +12264,6 @@ "notShared": "일부 영역은 공유되지 않았어요.", "inTimeline": "타임라인에서 보기", "askCoach": "이 날에 대해 코치에게 묻기", - "chartHint": "점을 클릭하면 그날이 열려요.", "loadFailed": "이 날을 불러오지 못했어요.", "dayOf": "{m}일 중 {n}일째", "dayN": "{n}일째", @@ -12248,16 +12285,10 @@ "captureShort": "기록", "inTimelineShort": "타임라인", "askCoachPrompt": "{date}에 무슨 일이 있었나요?", - "chartHintTouch": "점을 탭하면 값이 보여요.", - "chartHintBar": "막대를 클릭하면 그날이 열려요.", - "chartHintTouchBar": "막대를 탭하면 값이 보여요.", - "chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.", - "chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.", "coachChips": "이 답변에 쓰인 날", "description": "이 날에 대한 모든 기록이에요.", "openDocument": "문서 열기", "previousReading": "이전, {date}", - "rugLegend": "축 아래 점은 기록이 있는 날을, 고리는 눈에 띄는 날을 표시해요.", "sheetExpand": "전체 높이로 펼치기", "sheetShrink": "줄이기", "valuesLegend": "점은 이 날, 막대는 직전 30일", @@ -12560,8 +12591,8 @@ }, "palette": { "title": "검색", - "description": "페이지, 설정, 모듈 또는 작업을 찾습니다.", - "placeholder": "페이지, 설정, 모듈 검색…", + "description": "페이지, 설정, 모듈, 작업 또는 내 기록의 항목을 찾습니다.", + "placeholder": "페이지, 설정, 내 기록 검색…", "results": "결과", "noResults": "“{query}”에 대한 결과가 없습니다.", "moduleOn": "켜짐", @@ -12575,7 +12606,8 @@ "insights": "인사이트", "settings": "설정", "modules": "모듈", - "actions": "작업" + "actions": "작업", + "records": "내 기록에서" }, "actions": { "capture": "새 항목", @@ -12583,6 +12615,28 @@ "backup": "백업 만들기", "today": "오늘 열기", "shortcuts": "키보드 단축키 보기" + }, + "records": { + "loading": "내 기록을 검색하는 중…", + "empty": "내 기록에서 “{query}”에 대한 결과가 없습니다.", + "error": "내 기록을 검색할 수 없습니다. 잠시 후 다시 시도하세요.", + "truncated": "최근 항목만 검색했습니다.", + "kind": { + "visit": "진료", + "condition": "질환", + "document": "문서", + "vaccination": "예방접종", + "lab": "검사 결과", + "medication": "약", + "symptom": "증상", + "workout": "운동", + "note": "메모" + }, + "source": { + "mood": "기분 메모", + "measurement": "측정값 메모", + "illness": "질병 일지" + } } }, "shortcuts": { diff --git a/messages/pl.json b/messages/pl.json index 19bdad46a..a8d64dd6c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Zapisano w dzienniku choroby: {count} objawów", "bodyNotLogged": "Na ten dzień nic nie zapisano w dzienniku choroby", "openIllness": "Otwórz" - } + }, + "sourceNotification": "Powiadomienie" }, "medications": { "title": "Leki", @@ -1322,6 +1323,14 @@ "disabledToast": "Rejestrowanie przyjęć wyłączone", "toggleFailed": "Nie udało się zmienić rejestrowania przyjęć." }, + "autoMarkTaken": { + "label": "Zapisuj przyjęcia automatycznie", + "helper": "HealthLog zapisuje każdą zaplanowaną dawkę jako przyjętą o jej porze. Nadal możesz ręcznie zmienić lub pominąć dawkę. Przypomnienia nie są wysyłane.", + "cardHint": "Zapisywane automatycznie", + "enabledToast": "Przyjęcia są teraz zapisywane automatycznie", + "disabledToast": "Przyjęcia nie są już zapisywane automatycznie", + "toggleFailed": "Nie udało się zmienić automatycznego zapisu." + }, "withoutNotification": "Bez powiadomienia", "pausedSince": "Wstrzymane od", "takeNow": "Przyjmij teraz", @@ -1778,6 +1787,7 @@ "sourceImport": "Import", "sourceAppleHealth": "Apple Health", "intakeSourceVia": "przez {origin}", + "intakeRecordedAutomatically": "Zapisano automatycznie", "intakeHistoryEmptyTitle": "Brak przyjęć", "intakeHistoryEmptyDescription": "Gdy tylko zarejestrujesz przyjęcie, pojawi się tu wraz z godziną i źródłem.", "intakeHistoryEmptyAction": "Zarejestruj przyjęcie", @@ -2843,7 +2853,7 @@ "coach": { "refusalOutOfScope": "Mogę pomóc tylko w sprawach danych zdrowotnych z twojego logu: pomiarów, leków, nastroju, trendów. Pytań spoza tego zakresu nie podejmuję.", "refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne. Przeformułuj pytanie w tych ramach.", - "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).", + "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy w Twojej strefie czasowej.", "errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.", "errorDemo": "W wersji demo Coach nie odpowiada.", "errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI: połącz własny klucz lub model lokalny.", @@ -2999,7 +3009,7 @@ "settingsSaved": "Zapisano", "settingsCancel": "Anuluj", "dailyLimitTitle": "Osiągnięto dzienny limit", - "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", + "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy w Twojej strefie czasowej.", "providerRateLimitTitle": "Dostawca z ograniczeniem", "providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.", "cluster": { @@ -5325,7 +5335,8 @@ "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.", "remindersHeading": "Przypomnienia", "matrixLink": "Powiadomienia wg kanału", - "channelsLink": "Kanały dostarczania" + "channelsLink": "Kanały dostarczania", + "displayHeading": "Na telefonie" }, "layout": { "title": "Układ", @@ -6830,6 +6841,7 @@ "nocreds": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.", "token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", + "issuer": "Odpowiedź logowania nie pochodziła z serwera autoryzacji Oura, więc połączenie zostało przerwane dla Twojego bezpieczeństwa. Rozpocznij połączenie ponownie. Jeśli to się powtarza, powiadom administratora.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." }, "integrationSetupGuide": "Przewodnik konfiguracji", @@ -7237,7 +7249,7 @@ "typeCodex": "Logowanie ChatGPT (osobiste)", "typeLocal": "Model lokalny (osobisty)", "typeGateway": "Brama zgodna z OpenAI (osobista)", - "spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora.", + "spendToday": "Dzisiaj, według dnia kalendarzowego każdego konta: {total} tokenów, w tym {operator} na kluczu operatora (konta ze zużyciem: {accounts}).", "statusPaused": "Wstrzymany", "pausedHint": "Wstrzymany po powtarzających się błędach dla {count} użytkowników do {at}. Potem zostanie automatycznie ponowiony.", "modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje." @@ -8459,6 +8471,25 @@ "changedRead": "Możesz teraz przeglądać dokumentację zdrowotną osoby pod Twoją opieką, ale nie możesz jej zmieniać.", "changedManage": "Możesz zarządzać dokumentacją zdrowotną osoby pod Twoją opieką." } + }, + "links": { + "open": "Otwórz w HealthLog" + }, + "quickActions": { + "moodGood": "Dobrze", + "moodOkay": "W porządku", + "moodBad": "Źle" + }, + "content": { + "title": "Wartości zdrowotne w powiadomieniach", + "description": "Czy powiadomienie może pokazywać wartości, takie jak poranny wynik.", + "detail": "Wyłączone: powiadomienie mówi tylko, czego dotyczy, więc ekran blokady nie pokazuje wartości zdrowotnych. Włączone: dodaje wartości, na przykład poranny wynik w podsumowaniu. Dotknięcie powiadomienia w obu przypadkach otwiera właściwe miejsce w aplikacji.", + "toggleAria": "Pokazuj wartości zdrowotne w powiadomieniach", + "statusDetailed": "Z wartościami", + "statusGeneric": "Bez wartości", + "detailedToast": "Powiadomienia zawierają teraz wartości zdrowotne.", + "genericToast": "Powiadomienia nie zawierają już wartości zdrowotnych.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie później." } }, "i18n": { @@ -10858,6 +10889,7 @@ "saveError": "Nie udało się zapisać zmiany.", "deleteError": "Nie udało się usunąć dokumentu.", "download": "Pobierz", + "openInNewTab": "Otwórz w nowej karcie", "delete": "Usuń", "titleLabel": "Tytuł", "titlePlaceholder": "Dodaj tytuł", @@ -11261,7 +11293,13 @@ }, "push": { "title": "Twoje poranne podsumowanie", - "bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy." + "bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy.", + "titleScore": "Poranny wynik {score}", + "band": { + "green": "Dziś wszystko wygląda stabilnie.", + "yellow": "Dziś warto przyjrzeć się kilku rzeczom.", + "red": "Dziś niektóre obszary wymagają uwagi." + } }, "item": { "doseWindow": { @@ -12226,7 +12264,6 @@ "notShared": "Niektóre obszary nie są Ci udostępnione.", "inTimeline": "Na osi czasu", "askCoach": "Zapytaj Coacha o ten dzień", - "chartHint": "Kliknij punkt, aby otworzyć jego dzień.", "loadFailed": "Nie udało się wczytać tego dnia.", "dayOf": "Dzień {n} z {m}", "dayN": "Dzień {n}", @@ -12248,16 +12285,10 @@ "captureShort": "Zapisz", "inTimelineShort": "Oś czasu", "askCoachPrompt": "Co się działo {date}?", - "chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.", - "chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.", - "chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.", - "chartHintCell": "Kliknij dzień, aby go otworzyć.", - "chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.", "coachChips": "Dni w tej odpowiedzi", "description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.", "openDocument": "Otwórz dokument", "previousReading": "Wcześniej, {date}", - "rugLegend": "Punkty pod osią oznaczają dni z wpisami, pierścień dzień godny uwagi.", "sheetExpand": "Rozwiń na pełną wysokość", "sheetShrink": "Zmniejsz", "valuesLegend": "Punkt to ten dzień, pasek to 30 dni wcześniej", @@ -12560,8 +12591,8 @@ }, "palette": { "title": "Szukaj", - "description": "Znajdź stronę, ustawienie, moduł lub akcję.", - "placeholder": "Szukaj stron, ustawień, modułów…", + "description": "Znajdź stronę, ustawienie, moduł, akcję lub wpis w swoich danych.", + "placeholder": "Szukaj stron, ustawień, swoich wpisów…", "results": "Wyniki", "noResults": "Brak wyników dla „{query}”.", "moduleOn": "Wł.", @@ -12575,7 +12606,8 @@ "insights": "Insights", "settings": "Ustawienia", "modules": "Moduły", - "actions": "Akcje" + "actions": "Akcje", + "records": "W twoich wpisach" }, "actions": { "capture": "Nowy wpis", @@ -12583,6 +12615,28 @@ "backup": "Utwórz kopię zapasową", "today": "Otwórz dzisiaj", "shortcuts": "Pokaż skróty klawiszowe" + }, + "records": { + "loading": "Przeszukiwanie twoich wpisów…", + "empty": "Brak wyników w twoich wpisach dla „{query}”.", + "error": "Nie udało się przeszukać twoich wpisów. Spróbuj ponownie za chwilę.", + "truncated": "Przeszukano tylko najnowsze wpisy.", + "kind": { + "visit": "Wizyta", + "condition": "Choroba", + "document": "Dokument", + "vaccination": "Szczepienie", + "lab": "Wynik badania", + "medication": "Lek", + "symptom": "Objaw", + "workout": "Trening", + "note": "Notatka" + }, + "source": { + "mood": "Notatka o nastroju", + "measurement": "Notatka do pomiaru", + "illness": "Dziennik choroby" + } } }, "shortcuts": { diff --git a/package.json b/package.json index d4f4fd06a..52e5227a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.42.1", + "version": "1.42.2", "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/0395_medication_auto_mark_taken/migration.sql b/prisma/migrations/0395_medication_auto_mark_taken/migration.sql new file mode 100644 index 000000000..ddabd35c9 --- /dev/null +++ b/prisma/migrations/0395_medication_auto_mark_taken/migration.sql @@ -0,0 +1,7 @@ +-- Per-medication option to record each scheduled dose as taken without a +-- tap, the instant it was last switched on (no slot before it is filled), +-- and the marker on an intake the auto-take job recorded. +ALTER TABLE "medications" ADD COLUMN "auto_mark_taken" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "medications" ADD COLUMN "auto_mark_taken_since" TIMESTAMPTZ(6); +ALTER TABLE "medication_intake_events" ADD COLUMN "auto_recorded" BOOLEAN NOT NULL DEFAULT false; + diff --git a/prisma/migrations/0397_measurements_noted_live_idx/migration.sql b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql new file mode 100644 index 000000000..de777e276 --- /dev/null +++ b/prisma/migrations/0397_measurements_noted_live_idx/migration.sql @@ -0,0 +1,21 @@ +-- v1.42.2 — the record search reads the notes on readings newest first. +-- +-- A record can hold millions of readings and a handful of notes. Without an +-- index that holds only the noted rows, the search walks the account's whole +-- `(user_id, measured_at)` range to find them. This partial index holds the +-- live rows that carry a note (the encrypted column, or the legacy plaintext +-- one the boot backfill has not migrated yet), so the read is the handful. +-- +-- Additive only, no table rewrite, no data change. Non-concurrent for the +-- reason migration 0243 gives: Prisma Migrate runs a migration file in a +-- transaction. The build is not short on a large instance: the predicate +-- only decides which rows land in the index, so Postgres still scans the +-- whole `measurements` table to find them, and the non-concurrent build +-- blocks writes to the table while it runs. That is acceptable because the +-- entrypoint applies migrations before the app serves traffic, so no request +-- is waiting on those writes; an upgrade on a table with many millions of +-- readings simply takes longer to come up. +CREATE INDEX IF NOT EXISTS "measurements_noted_live_idx" + ON "measurements" ("user_id", "measured_at" DESC, "id" DESC) + WHERE "deleted_at" IS NULL + AND ("notes_encrypted" IS NOT NULL OR "notes" IS NOT NULL); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99eb867c2..20b178b71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,8 @@ // round-trip the same instant. The invariant every reader, writer, and // raw-SQL query MUST hold is: // -// * The application server and the Postgres session run in UTC. +// * The application server and the Postgres session run in UTC (every +// connection pins TimeZone=UTC, see src/lib/db-session.ts). // * Wall-clock / calendar-local anchoring lives in an explicit `tz` // string column (e.g. `MoodEntry.tz`) or the user's `timezone`, NOT // in the column type. @@ -2095,6 +2096,12 @@ model Measurement { // without this one the purge planned a sequential scan and could not finish // inside statement_timeout. Raw SQL only (partial predicate not expressible // in Prisma). + // `measurements_noted_live_idx` is a partial index over `(user_id, + // measured_at DESC, id DESC) WHERE deleted_at IS NULL AND (notes_encrypted + // IS NOT NULL OR notes IS NOT NULL)` — see migration 0397 (v1.42.2). The + // record search reads the notes on readings newest first; without it a + // record of millions of readings with a few notes walks every reading. + // Raw SQL only (partial predicate not expressible in Prisma). @@map("measurements") } @@ -2793,6 +2800,20 @@ model Medication { /// (the stretch in between archives with an empty payload), so the rate /// paths never count the untracked stretch as missed. trackIntake Boolean @default(true) @map("track_intake") + /// v1.42.2 — record every scheduled dose as taken without a tap. For a + /// person who takes a fixed dose reliably (evening supplements) and does not + /// want to confirm it each day. The `intake-auto-take` job writes a taken + /// intake for each slot of a tracked, active, unpaused medication once the + /// slot's time has passed, marked `autoRecorded`; a slot the person already + /// took, skipped or removed is never touched, and no slot before + /// `autoMarkTakenSince` is ever filled. While on, no medication reminder is + /// sent for it on any channel, and the hourly auto-miss pass leaves the + /// slots this job owns alone. + autoMarkTaken Boolean @default(false) @map("auto_mark_taken") + /// v1.42.2 — when `autoMarkTaken` was last switched on. The auto-take job + /// never fills a slot anchored before it, so switching the option on does + /// not rewrite the history before that moment. NULL while the option is off. + autoMarkTakenSince DateTime? @map("auto_mark_taken_since") @db.Timestamptz(6) /// v1.6.0 — route of administration. Decoupled from `treatmentClass` /// so the injection-site picker can surface for any INJECTION dose. /// A one-time injection is `oneShot = true` + `deliveryForm = @@ -3298,6 +3319,12 @@ model MedicationIntakeEvent { /// legacy row defaults to false (a user skip or a live pending row, /// neither of which is an auto-miss). autoMissed Boolean @default(false) @map("auto_missed") + /// v1.42.2 — true when the `intake-auto-take` job recorded this dose + /// because the medication has `autoMarkTaken` on, not the person. It counts + /// as taken everywhere (compliance, history, Coach); history lists label it + /// "recorded automatically". Any explicit take or skip of the slot by the + /// person clears it. + autoRecorded Boolean @default(false) @map("auto_recorded") /// v1.15.20 — how this row's `scheduledFor` slot binding came to be. /// `AUTO` (default) = the write path attributed the take by window-band /// membership (or the row is a skip / pending mint anchored on its slot). diff --git a/public/sw.js b/public/sw.js index 71af802bd..02420a92e 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.42.1"; + /* @sw-version-fallback */ "v1.42.2"; 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__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index 272865a8e..697cba90e 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -154,6 +154,13 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { ).toBe(403); }); + it("permits the record search, a read that travels as a POST", () => { + // The query rides the body so it stays out of URLs and access logs; the + // route writes nothing, so the demo searches like any other instance. + expect(proxy(makeRequest("/api/search", "POST")).status).not.toBe(403); + expect(proxy(makeRequest("/api/search", "DELETE")).status).toBe(403); + }); + it("still blocks a health-data mutation (POST /api/measurements)", () => { const res = proxy(makeRequest("/api/measurements", "POST")); expect(res.status).toBe(403); diff --git a/src/__tests__/read-over-post-guard.test.ts b/src/__tests__/read-over-post-guard.test.ts new file mode 100644 index 000000000..c699a9ad4 --- /dev/null +++ b/src/__tests__/read-over-post-guard.test.ts @@ -0,0 +1,34 @@ +/** + * `readOverPost` lets a delegable route answer a POST at the grant's read + * level. That widens what a read-only grant reaches, so the set of routes + * declaring it is frozen here: adding one is a reviewed diff, not an + * accident. Today it is the record search alone, whose query is health data + * that must stay out of URLs. + * + * The matcher tolerates whitespace and line breaks, and the guard asserts it + * matched something, so a rewrite that hides the declaration from the regex + * fails instead of passing on an empty set. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { globSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +const ROOT = process.cwd(); + +const FROZEN = ["src/app/api/search/route.ts"]; + +describe("readOverPost declarations", () => { + it("are exactly the frozen set", () => { + const declaring = globSync("src/**/*.ts", { cwd: ROOT }) + .filter((file) => !file.includes(`${sep}__tests__${sep}`)) + .filter((file) => file !== join("src", "lib", "api-handler.ts")) + .filter((file) => + /readOverPost\s*:\s*true/.test(readFileSync(join(ROOT, file), "utf8")), + ) + .map((file) => relative(ROOT, join(ROOT, file)).split(sep).join("/")) + .sort(); + expect(declaring.length).toBeGreaterThan(0); + expect(declaring).toEqual(FROZEN); + }); +}); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index b06ac7b19..ebf46edd7 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -271,6 +271,13 @@ describe("S2 — the URL-token authentication set is frozen", () => { // renders nothing about the profile before the preview answers. "app/auth/claim/page.tsx": "carries the handover token from the query string into the claim requests' body", + // v1.42.2 — notification quick answers. A notification app (ntfy's + // `http` action) calls it with no session, so the signed, user- and + // action-bound, short-lived token in the path is the credential. Rate + // limited before verification, single-use through the mood entry's unique + // key, and scrubbed from logs by `PATH_SECRET_PATHS`. + "app/api/notifications/action/[token]/route.ts": + "signed single-use quick-answer token from a notification action", }; /** @@ -304,6 +311,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { expect(bySegment.length).toBeGreaterThan(0); expect(bySegment).toEqual([ "app/api/c/[token]/unlock/route.ts", + "app/api/notifications/action/[token]/route.ts", "app/api/whoop/webhook/[token]/route.ts", "app/api/withings/webhook/[token]/route.ts", "app/c/[token]/d/[id]/route.ts", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index b7b768b4b..50035b43b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1045,6 +1045,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.", }, + "app/api/search/route.ts": { + domain: "record", + why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -2094,7 +2098,7 @@ const ACTOR_ROUTES: Record = { * literal (a score's daily course, MANAGE like the derived score itself). * 252 -> 254. */ -const FROZEN_ENTRY_COUNT = 254; +const FROZEN_ENTRY_COUNT = 255; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts new file mode 100644 index 000000000..8846c7622 --- /dev/null +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -0,0 +1,97 @@ +/** + * Raw SQL never reads the clock through the session time zone. + * + * Stored times are UTC in `timestamp` columns without a zone. Postgres + * compares such a column with `NOW()` / `CURRENT_TIMESTAMP` / + * `clock_timestamp()` by converting through the session time zone, and + * assigns one of them to such a column the same way, so on a database whose + * default is not UTC a fresh passkey challenge read as already expired. Every + * connection is pinned to UTC (`@/lib/db-session`), and on top of that every + * raw-SQL clock read writes `NOW() AT TIME ZONE 'UTC'`, which yields the naive + * UTC timestamp whatever the session says. + * + * The few bare reads left are listed below with the reason they are safe: + * each feeds a `timestamptz` column or returns the instant to JavaScript. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); +const SRC = join(ROOT, "src"); + +/** `file: reason` for every bare clock read that is deliberately left. */ +const ALLOWED: Record = { + "src/lib/rollups/mood-rollups.ts": "computed_at is timestamptz", + "src/lib/rollups/medication-compliance-rollups.ts": + "computed_at is timestamptz", + "src/lib/jobs/job-failures.ts": "pg-boss tables are timestamptz", + "src/lib/jobs/reminder/registrar-shared.ts": "pg-boss tables are timestamptz", + "src/lib/db-compat.ts": "DDL default on a timestamptz column", +}; + +const SQL_CLOCK = + /(?]|=)\s*now\(\)\s*(?:$|[,)]|[-+]\s*(?:make_interval|INTERVAL))|\bnow\(\)\s*[-+]\s*(?:make_interval|INTERVAL)/; +const ANCHORED = + /(?:NOW\(\)|CURRENT_TIMESTAMP|clock_timestamp\(\)) AT TIME ZONE 'UTC'/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (name === "generated" || name === "__tests__") continue; + if (statSync(full).isDirectory()) walk(full, out); + else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) + out.push(full); + } + return out; +} + +function offenders(): string[] { + const found: string[] = []; + for (const file of walk(SRC)) { + const rel = relative(ROOT, file); + if (ALLOWED[rel]) continue; + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + if (/^\s*(\/\/|\*|\/\*|--)/.test(line)) return; + if (SQL_CLOCK.test(line.replace(ANCHORED, ""))) { + found.push(`${rel}:${index + 1}: ${line.trim()}`); + } + }); + } + return found; +} + +describe("raw SQL clock reads are anchored to UTC", () => { + it("finds the anchored form across the swept files", () => { + // The matcher must see something, or a broken walk would pass. + const passkey = readFileSync(join(SRC, "lib/auth/passkey.ts"), "utf8"); + expect(passkey.match(ANCHORED)?.length).toBeGreaterThanOrEqual(3); + expect(SQL_CLOCK.test("AND expires_at > NOW()")).toBe(true); + expect(SQL_CLOCK.test("updated_at = now()")).toBe(true); + expect(SQL_CLOCK.test("const started = now();")).toBe(false); + expect(SQL_CLOCK.test("elapsedMs: () => now() - started,")).toBe(false); + expect(SQL_CLOCK.test("const ends = now() + perCallMs;")).toBe(false); + expect( + SQL_CLOCK.test("deleted_at >= now() - make_interval(days => 1)"), + ).toBe(true); + expect(SQL_CLOCK.test("Date.now()")).toBe(false); + }); + + it("leaves no bare NOW() / CURRENT_TIMESTAMP outside the allowlist", () => { + expect(offenders()).toEqual([]); + }); + + it("keeps the allowlist honest: every entry still has a bare read", () => { + for (const rel of Object.keys(ALLOWED)) { + const lines = readFileSync(join(ROOT, rel), "utf8") + .replace(ANCHORED, "") + .split("\n"); + expect( + lines.some((line) => SQL_CLOCK.test(line)), + rel, + ).toBe(true); + } + }); +}); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index aba2dbe9e..c6023429a 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -276,6 +276,11 @@ const PINNED_AFFORDANCES: Record< "src/components/medications/sections/intake-tracking-section.tsx": { "toast.success": 1, }, + // v1.42.2 — the auto-mark-taken switch confirms the flip, like its + // intake-tracking sibling. + "src/components/medications/sections/auto-mark-taken-section.tsx": { + "toast.success": 1, + }, "src/components/medications/sections/inventory-section.tsx": { "toast.success": 1, }, diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 78b6fc9c8..b25c36d1d 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day"; +import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -182,7 +183,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const isOverdue = dayMatch && minutesPastEnd > 0; const hasEvent = eventCount > schedulesProcessed; - if (isOverdue && !hasEvent && med.notificationsEnabled) { + // v1.42.2 — a medication whose doses are recorded automatically + // is never reminded, here or in the worker. + if ( + isOverdue && + !hasEvent && + sendsMedicationReminders(med, { now, timeZone: userTz }) + ) { const doseInfo = schedule.dose ?? med.dose; const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`; diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 6735c919b..fa9f4259e 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(async () => []), }, coachUsage: { - aggregate: vi.fn(), + findMany: vi.fn(async () => []), }, // v1.42 — the operator key's model check reads the admin settings. appSettings: { findUnique: vi.fn(async () => null) }, @@ -59,7 +59,19 @@ import { prisma } from "@/lib/db"; import { requireAdmin } from "@/lib/api-handler"; const groupBy = vi.mocked(prisma.providerHealth.groupBy); -const aggregate = vi.mocked(prisma.coachUsage.aggregate); +const usageRows = vi.mocked(prisma.coachUsage.findMany); + +/** A spend row as the reader selects it. */ +function usage( + dateKey: string, + totalTokens: number, + operatorTokens: number, + timezone: string | null = "UTC", +) { + return { dateKey, totalTokens, operatorTokens, user: { timezone } }; +} + +const utcToday = () => new Date().toISOString().slice(0, 10); function group( providerType: string, @@ -91,9 +103,7 @@ describe("GET /api/admin/provider-health", () => { vi.mocked(requireAdmin).mockResolvedValue({ user: { id: "admin1" }, } as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: 0, operatorTokens: 0 }, - } as never); + usageRows.mockResolvedValue([] as never); }); it("is admin-gated before it touches the ledger", async () => { @@ -231,31 +241,44 @@ describe("GET /api/admin/provider-health", () => { */ it("reports the day's spend split by who paid for it", async () => { groupBy.mockResolvedValue([] as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: 1_240_000, operatorTokens: 151_200 }, - } as never); + usageRows.mockResolvedValue([ + usage(utcToday(), 1_000_000, 100_000), + usage(utcToday(), 240_000, 51_200), + ] as never); const res = await GET(); const body = await res.json(); expect(body.data.spendToday).toEqual({ - dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), totalTokens: 1_240_000, operatorTokens: 151_200, + accounts: 2, + }); + }); + + it("counts each account on its own today and nothing from another day", async () => { + groupBy.mockResolvedValue([] as never); + const yesterday = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + usageRows.mockResolvedValue([ + usage(utcToday(), 500, 100), + // The same UTC account's yesterday: already closed, not today's spend. + usage(yesterday, 9_999, 9_999), + ] as never); + + const res = await GET(); + const body = await res.json(); + expect(body.data.spendToday).toEqual({ + totalTokens: 500, + operatorTokens: 100, + accounts: 1, }); - // Today only, across every account — the operator's ceiling is per day. - expect(aggregate).toHaveBeenCalledWith( - expect.objectContaining({ - where: { dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }, - }), - ); }); it("reports a day with no spend as zero, not null", async () => { groupBy.mockResolvedValue([] as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: null, operatorTokens: null }, - } as never); + usageRows.mockResolvedValue([] as never); const res = await GET(); const body = await res.json(); @@ -281,9 +304,7 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", () vi.mocked(requireAdmin).mockResolvedValue({ user: { id: "admin" }, } as never); - aggregate.mockResolvedValue({ - _sum: { totalTokens: 0, operatorTokens: 0 }, - } as never); + usageRows.mockResolvedValue([] as never); findMany.mockResolvedValue([] as never); settings.mockResolvedValue(null as never); }); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 199d99923..8d35cada5 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -1,5 +1,5 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { buildDateKey } from "@/lib/ai/coach/budget"; +import { readInstanceSpendToday } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -76,9 +76,6 @@ export const GET = apiHandler(async () => { await requireAdmin(); annotate({ action: { name: "admin.provider-health.get" } }); - // The UTC day the ledger buckets by — the same key every reservation writes. - const dateKey = buildDateKey(); - const groups = await prisma.providerHealth.groupBy({ // `lastStatus` joins the grouping key rather than an aggregate: the // MAX of two HTTP statuses is not a status, it is arithmetic on a @@ -175,17 +172,19 @@ export const GET = apiHandler(async () => { // alarming until you see that 151 200 of them were the instance's own key // and the rest ran on the users' plans. The operator ceiling is enforced // against the second figure, so both belong on screen. - const spend = await prisma.coachUsage.aggregate({ - where: { dateKey }, - _sum: { totalTokens: true, operatorTokens: true }, - }); + // + // "Today" is each account's own calendar day, the day its ceilings are + // enforced on (`readInstanceSpendToday`), not one date for the instance: + // the budget day is per account local day, so a single date key would + // count some accounts' yesterday or tomorrow and miss part of their today. + const spend = await readInstanceSpendToday(); return apiSuccess({ providers, spendToday: { - dateKey, - totalTokens: spend._sum.totalTokens ?? 0, - operatorTokens: spend._sum.operatorTokens ?? 0, + totalTokens: spend.total, + operatorTokens: spend.operator, + accounts: spend.accounts, }, }); }); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index eb8166511..1a1e25402 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -145,7 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // `admin-key` is the operator's own credential and draws the operator // ceiling; an override names the caller's own key, and every other resolved // type is the user's own egress, so those draw the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(user.id); const reservation = await reserveBudget( user.id, AI_TEST_MAX_TOKENS, diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index a833a6b22..c00dd9536 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -109,6 +109,7 @@ describe("GET /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -145,6 +146,7 @@ describe("GET /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -183,6 +185,7 @@ describe("GET /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); }); @@ -232,6 +235,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, // v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token. updatedAt: expect.any(String), }); @@ -260,6 +264,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); @@ -289,6 +294,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, next: { medication: { @@ -310,6 +316,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, changed: ["medication"], }), @@ -393,6 +400,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); @@ -432,6 +440,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, // v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token. updatedAt: expect.any(String), }); @@ -460,6 +469,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); @@ -505,11 +515,46 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); }); + it("persists content.detail and keeps the siblings", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { mood: { reminderHour: 7 } }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ content: { detail: "detailed" } }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { content: { detail: string }; mood: { reminderHour: number } }; + }; + expect(env.data.content).toEqual({ detail: "detailed" }); + expect(env.data.mood).toEqual({ reminderHour: 7 }); + const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + data: { notificationPrefs: { content: { detail: string } } }; + }; + expect(written.data.notificationPrefs.content).toEqual({ + detail: "detailed", + }); + }); + + it("rejects an unknown content.detail with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ content: { detail: "everything" } }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 1fc8338c5..e011909a8 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -183,7 +183,7 @@ async function reserveFor( providerType: ProviderChainType, tokens: number, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, tokens, diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 7c01083fc..514c266ba 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -200,7 +200,7 @@ async function handleVisionIndex( const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, DOCUMENT_INDEX_RESERVE_TOKENS, diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts index c759ae581..dea3c6a7c 100644 --- a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -240,6 +240,10 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)", ["image/heic", "photo.heic"], ["application/xml", "export.xml"], ["application/json", "export.json"], + // Upload refuses markup outright; a row that somehow carries one must + // still never render in-origin, where its script would run. + ["image/svg+xml", "drawing.svg"], + ["text/html", "page.html"], ])( "Class B %s → attachment + octet-stream, never inline", async (mime, name) => { diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 28ac50c64..da9d78fcc 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -122,7 +122,7 @@ async function reserveFor( userId: string, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, AI_BUDGETS.documentAssist.maxTokens, diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 9c2c7c484..c5e293890 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -216,7 +216,7 @@ async function reserveFor( ask: SummaryRequest, providerType: ProviderChainType, ): Promise { - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(ask.userId); const reservation = await reserveBudget( ask.userId, budgetFor(ask.mode), diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 3d1e59cf1..96bb99e0e 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -195,6 +195,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise { }, metadata: { source: "deploy-webhook", + url: "/admin/system-status", applicationName: event.applicationName, applicationUuid: event.applicationUuid, deploymentUuid: event.deploymentUuid, diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index 94a76ee03..a4e21e9d2 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -157,7 +157,7 @@ async function handleTextExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked provider // egresses on the operator's own key; a BYOK / Codex / local pick runs on the // user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.text, @@ -218,7 +218,7 @@ async function handleVisionExtract( // v1.21.0 (F1) — the operator-cost cap applies only when the picked vision // provider egresses on the operator's own key; a BYOK / Codex pick runs on // the user's own plan and gets the generous user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = await buildDateKey(userId); const reservation = await reserveBudget( userId, OCR_RESERVE_TOKENS.vision, diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 17049bf4c..a60dd7992 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -378,7 +378,7 @@ export const GET = apiHandler(async (request: NextRequest) => { AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND "deleted_at" IS NULL AND "value" >= $3 - AND "measured_at" >= date_trunc('day', $2::timestamptz) + AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC') ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text ) c ON c.d = date_trunc('day', m."measured_at") diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index d0cc378f6..2b9bb1370 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -92,6 +92,8 @@ interface SerializedDoseHistoryRow { takenAt: string | null; skipped: boolean; autoMissed: boolean; + /** v1.42.2 — recorded automatically (`autoMarkTaken`). */ + autoRecorded: boolean; /** v1.16.4 — per-intake dose override; null = configured dose. */ doseTaken: string | null; /** v1.32.8 (iOS #64) — how the dose was recorded; null on legacy rows. */ @@ -179,6 +181,8 @@ export const GET = apiHandler( // v1.32.8 (iOS #64) — write provenance so the client can label how // each dose was recorded (Web / API / Reminder / Import / AppleHealth). source: true, + // v1.42.2 — the history labels an automatically recorded dose. + autoRecorded: true, }, }); @@ -191,6 +195,7 @@ export const GET = apiHandler( pinned: e.attributionSource === "USER_PIN", doseTaken: e.doseTaken, source: e.source, + autoRecorded: e.autoRecorded, })); const lastIntakeAt = lastNonSkippedTakenAt(mapped); @@ -296,6 +301,7 @@ export const GET = apiHandler( pinned: e.pinned, doseTaken: e.doseTaken, source: e.source, + autoRecorded: e.autoRecorded, })); const rows = reconstructDoseHistory( @@ -327,6 +333,7 @@ export const GET = apiHandler( autoMissed: row.intake.autoMissed ?? false, doseTaken: row.intake.doseTaken ?? null, source: row.intake.source ?? null, + autoRecorded: row.intake.autoRecorded ?? false, } : null, })); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 2ae0d8840..7835f65df 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -252,6 +252,10 @@ export const PUT = apiHandler( // onto the slot the row already sits on, or an unpin whose band // re-attribution lands on the same anchor). ...(attributionSource !== undefined && { attributionSource }), + // v1.42.2 — an edit of the outcome is the person's own record. + ...((data.takenAt !== undefined || data.skipped !== undefined) && { + autoRecorded: false, + }), // v1.7.0 sync — bump the reconciliation counter on every // server-side mutation so the `/api/sync/changes` feed echoes a // monotonic value to paired clients. diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 3587a4dda..212716afe 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -31,6 +32,10 @@ import { toRevisionPayloadEntry, } from "@/lib/medications/scheduling/schedule-eras"; import type { Prisma } from "@/generated/prisma/client"; +import { + loadAutoTakeRollingAnchors, + moveAutoTakeFloorToNow, +} from "@/lib/medications/auto-take"; import { computeDisplayDue, OVERDUE_LOOKBACK_MS, @@ -178,6 +183,16 @@ export const GET = apiHandler( // v1.39.1 (#1033) — intake tracking off: nothing is due from the // stored rows, and no runway derives from them. const liveSchedules = dueSchedules(medication); + const userTz = user.timezone || DEFAULT_TIMEZONE; + // v1.42.2 — under auto-take a rolling cadence stands where the job will + // record next (see `auto-take.ts`). + const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { + userId: user.id, + medicationIds: medication.autoMarkTaken ? [id] : [], + now, + userTz, + lastIntakeAt: () => lastIntake?.takenAt ?? null, + }); const display = computeDisplayDue({ medication: { id: medication.id, @@ -188,8 +203,8 @@ export const GET = apiHandler( }, schedules: liveSchedules, now, - userTz: user.timezone || DEFAULT_TIMEZONE, - lastIntakeAt: lastIntake?.takenAt ?? null, + userTz, + lastIntakeAt: engineLastIntakeAt(id), resolvedSlots: resolvedRows.map(toResolvedSlotMark), eraStart: latestRevision?.validUntil ?? null, }); @@ -264,6 +279,11 @@ export const GET = apiHandler( categoryLabel, ...courseFields, nextDueAt: display ? display.at.toISOString() : null, + // v1.42.2 — the server's own reminder decision, published resolved. + remindersActive: sendsMedicationReminders(medication, { + now, + timeZone: userTz, + }), nextDueOverdue: display?.overdue ?? false, // v1.39.4 (#1040) — see `resolveIntakeActionability`. ...resolveIntakeActionability( @@ -310,6 +330,8 @@ export const PUT = apiHandler( asNeeded: true, // v1.39.1 (#1033) — the tracking transition below needs the before. trackIntake: true, + // v1.42.2 — `autoMarkTakenSince` is stamped only on an off→on flip. + autoMarkTaken: true, // v1.37.0 — the pre-image the audit row carries (C4). name: true, dose: true, @@ -407,6 +429,7 @@ export const PUT = apiHandler( oneShot, asNeeded, trackIntake, + autoMarkTaken, reminderGraceMinutes: topLevelGraceMinutes, } = input; @@ -962,6 +985,15 @@ export const PUT = apiHandler( ...(asNeeded !== undefined && { asNeeded }), // v1.39.1 (#1033) — intake tracking, field-by-field. ...(trackIntake !== undefined && { trackIntake }), + // v1.42.2 — auto-mark-taken, field-by-field. Only an off→on flip + // stamps `autoMarkTakenSince`, so re-saving the form with the + // option already on never moves the floor; switching it off + // clears the stamp. + ...(autoMarkTaken !== undefined && + autoMarkTaken !== existing.autoMarkTaken && { + autoMarkTaken, + autoMarkTakenSince: autoMarkTaken ? new Date() : null, + }), ...(normalisedSchedules && { schedules: { create: normalisedSchedules.map((n) => n.createData), @@ -996,6 +1028,9 @@ export const PUT = apiHandler( where: { medicationId: id, userId: user.id, resumedAt: null }, data: { resumedAt: new Date() }, }); + // v1.42.2 — made active again: the auto-take pass starts from + // now, never filling the inactive days as taken. + await moveAutoTakeFloorToNow(tx, id); } // v1.40 (#1024) — write the window onto the latest course and @@ -1102,6 +1137,9 @@ export const PUT = apiHandler( active: existing.active, asNeeded: existing.asNeeded, trackIntake: existing.trackIntake, + // v1.42.2 — switching automatic recording on or off is a change + // to how doses are recorded, so the row names it. + autoMarkTaken: existing.autoMarkTaken, endsOn: existing.endsOn, }, after: { @@ -1110,6 +1148,7 @@ export const PUT = apiHandler( active: medication.active, asNeeded: medication.asNeeded, trackIntake: medication.trackIntake, + autoMarkTaken: medication.autoMarkTaken, endsOn: medication.endsOn, }, }), diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index c8bee510e..aaf949bfb 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -225,7 +225,10 @@ async function handleExtract(request: NextRequest): Promise { chain.map((entry) => entry.providerType), ); - const today = todayOverride ?? buildDateKey(); + // The account's own today: "tomorrow" in a description means the + // person's tomorrow, and the same key is the budget day below. + const accountToday = await buildDateKey(userId); + const today = todayOverride ?? accountToday; const { systemPrompt, userPrompt } = buildMedicationExtractionPrompt({ text, today, @@ -239,7 +242,7 @@ async function handleExtract(request: NextRequest): Promise { // paying for their own key by the operator's ceiling. `resolveDailyCap` // charges the operator ceiling only when the chain's primary is the // operator's own credential; every other chain gets the user-plan ceiling. - const dateKey = buildDateKey(); + const dateKey = accountToday; const estimatedTokens = MAX_OUTPUT_TOKENS + Math.ceil((systemPrompt.length + userPrompt.length) / 4); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 473eb90f9..9e033bcca 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -438,7 +438,12 @@ describe("POST /api/medications/intake", () => { // v1.7.0 sync — the skip toggle bumps syncVersion. expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith({ where: { id: "e1" }, - data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } }, + data: { + takenAt: null, + skipped: true, + autoRecorded: false, + syncVersion: { increment: 1 }, + }, }); // v1.16.10 — a row toggled out of taken refunds its consumption // stamp (no-op for a never-consumed row) and never consumes. diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index e4f6b0ca7..d746249e2 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -438,6 +438,8 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { takenAt: resolvedTakenAt, skipped: false, + // v1.42.2 — the person confirmed it; no longer automatic. + autoRecorded: false, syncVersion: { increment: 1 }, // v1.8.5 — persist the resolved site on the taken branch. ...(resolvedInjectionSite !== null && { @@ -525,7 +527,13 @@ export const POST = apiHandler(async (request: NextRequest) => { updated = await prisma.medicationIntakeEvent.update({ where: { id: intakeId }, // v1.7.0 sync — bump the reconciliation counter on the skip toggle. - data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } }, + // v1.42.2 — a skip the person records clears the automatic marker. + data: { + takenAt: null, + skipped: true, + autoRecorded: false, + syncVersion: { increment: 1 }, + }, }); // v1.16.10 — the row stopped being taken; refund whatever its // consumption stamp recorded (no-op for a never-consumed row). diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 4ebc3e955..e2fd46d76 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -189,6 +189,7 @@ async function postMedication(request: NextRequest): Promise { oneShot, asNeeded, trackIntake, + autoMarkTaken, } = parsed.data; // v1.40 (#1041) — a custom category must be one of this record's own. @@ -378,6 +379,12 @@ async function postMedication(request: NextRequest): Promise { // the schedule is stored as a record from the start; the column // default (true) covers every caller that does not send it. ...(trackIntake !== undefined && { trackIntake }), + // v1.42.2 — auto-mark-taken, field-by-field. Created on, the + // auto-take job fills slots from this moment only. + ...(autoMarkTaken !== undefined && { + autoMarkTaken, + autoMarkTakenSince: autoMarkTaken ? new Date() : null, + }), schedules: { create: scheduleInputs.map((s) => { // Invariant 2 — default to FREQ=DAILY when nothing else is set. @@ -484,7 +491,13 @@ async function postMedication(request: NextRequest): Promise { await auditLog("medication.create", { userId: user.id, ipAddress: getClientIp(request), - details: { medicationId: medication.id, name }, + details: { + medicationId: medication.id, + name, + // v1.42.2 — a medication created with its doses recorded + // automatically says so in its audit row. + autoMarkTaken: medication.autoMarkTaken, + }, }); annotate({ diff --git a/src/app/api/notifications/action/[token]/__tests__/route.test.ts b/src/app/api/notifications/action/[token]/__tests__/route.test.ts new file mode 100644 index 000000000..728fc42eb --- /dev/null +++ b/src/app/api/notifications/action/[token]/__tests__/route.test.ts @@ -0,0 +1,199 @@ +/** + * POST /api/notifications/action/{token} — a quick answer from a + * notification. Pins the order of the gates and every refusal class: rate + * limit before the token is read, then signature, expiry, account, module, + * and the single-use write. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + idempotencyKey: { create: vi.fn(), deleteMany: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({ "Retry-After": "60" })), +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); +vi.mock("@/lib/mood/create-from-telegram", () => ({ + logTelegramMood: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + ACTION_TOKEN_TTL_MS, + mintActionToken, +} from "@/lib/notifications/action-token"; + +function call(token: string) { + const req = new NextRequest( + `http://localhost/api/notifications/action/${token}`, + { method: "POST" }, + ); + return POST(req, { params: Promise.resolve({ token }) }); +} + +function token(over: Partial[0]> = {}) { + return mintActionToken({ + userId: "user-1", + action: "mood-okay", + nonce: "nonce-1", + ...over, + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: new Date(), + ip: "203.0.113.5", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + timezone: "Europe/Berlin", + } as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never); + vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(logTelegramMood).mockResolvedValue({ + created: true, + moodEntryId: "m1", + score: 3, + }); +}); + +describe("POST /api/notifications/action/{token}", () => { + it("logs the answer under the reminder's nonce with the notification source", async () => { + const res = await call(token()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + data: { logged: true, action: "mood-okay" }, + error: null, + }); + expect(logTelegramMood).toHaveBeenCalledWith({ + userId: "user-1", + score: 3, + tz: "Europe/Berlin", + externalId: "notification-action:nonce-1", + source: "NOTIFICATION", + }); + }); + + it("is rate-limited before the token is read", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date(), + ip: "203.0.113.5", + } as never); + const res = await call(token()); + expect(res.status).toBe(429); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a tampered token with 401 and writes nothing", async () => { + const forged = token().replace("user-1", "user-2"); + const res = await call(forged); + expect(res.status).toBe(401); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses an expired token with 410", async () => { + const res = await call( + token({ now: Date.now() - ACTION_TOKEN_TTL_MS - 1 }), + ); + expect(res.status).toBe(410); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a token for an account that no longer exists", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + const res = await call(token()); + expect(res.status).toBe(401); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("writes only for the user the token names", async () => { + await call(token({ userId: "user-9" })); + expect(prisma.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "user-9" } }), + ); + }); + + it("refuses with 403 when the mood module was switched off", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const res = await call(token()); + expect(res.status).toBe(403); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mood"); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a reused reminder with 409", async () => { + vi.mocked(logTelegramMood).mockResolvedValue({ + created: false, + moodEntryId: "m1", + score: 3, + }); + const res = await call(token({ action: "mood-bad" })); + expect(res.status).toBe(409); + }); + + it("refuses a spent nonce with 409 before writing anything", async () => { + const { Prisma } = await import("@/generated/prisma/client"); + vi.mocked(prisma.idempotencyKey.create).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("unique", { + code: "P2002", + clientVersion: "test", + }), + ); + const res = await call(token()); + expect(res.status).toBe(409); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("claims the nonce until the token expires", async () => { + const now = Date.now(); + await call(token({ now })); + expect(prisma.idempotencyKey.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user-1", + key: "notification-action:nonce-1", + requestFingerprint: null, + expiresAt: new Date(now + ACTION_TOKEN_TTL_MS), + }), + }); + }); + + it("gives the claim back when the write fails for another reason", async () => { + vi.mocked(logTelegramMood).mockRejectedValue(new Error("db down")); + const res = await call(token()); + expect(res.status).toBe(500); + expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + userId: "user-1", + key: "notification-action:nonce-1", + }), + }); + }); +}); diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts new file mode 100644 index 000000000..cbf074576 --- /dev/null +++ b/src/app/api/notifications/action/[token]/route.ts @@ -0,0 +1,171 @@ +/** + * `POST /api/notifications/action/` — a quick answer tapped inside a + * notification. + * + * The caller is a notification app (ntfy's `http` action), not a browser + * with a session, so the signed path segment is the whole credential. What + * the token proves and how it is kept single-use is in + * `src/lib/notifications/action-token.ts`; this route only applies it: + * + * 1. Rate-limit per client address BEFORE looking at the token, so the + * route is no oracle for guessing one (the anonymous-surface limiter + * collapses a broken proxy trust chain into one tight bucket). + * 2. Verify the signature (constant-time) and the expiry. + * 3. Re-check what may have changed since the reminder went out: the + * account still exists and its mood module is still on. + * 4. Claim the reminder's nonce (`action-nonce.ts`), then write the mood + * entry under it. The claim and the entry's unique + * `(userId, source, externalId)` key each make the first answer the only + * one: a repeat tap, or a different answer on the same reminder, is + * refused with 409 and writes nothing. The claim is what still holds + * after the entry was deleted and purged inside the token's lifetime. + * + * The token travels in the path, so it is scrubbed from every log line by + * the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the + * URL-credential inventory in `session-surface-guard.test.ts`. + */ +import { NextRequest } from "next/server"; + +import { Prisma } from "@/generated/prisma/client"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + claimActionNonce, + releaseActionNonce, +} from "@/lib/notifications/action-nonce"; +import { + MOOD_QUICK_ACTIONS, + quickActionExternalId, + verifyActionToken, +} from "@/lib/notifications/action-token"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +/** Generous for one person tapping, tight for anyone enumerating. */ +const RATE_LIMIT = 20; +const RATE_WINDOW_MS = 10 * 60 * 1000; + +interface RouteContext { + params: Promise<{ token: string }>; +} + +function isUniqueViolation(err: unknown): boolean { + return ( + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" + ); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteContext) => { + annotate({ action: { name: "notification.action.invoke" } }); + + const rl = await checkAuthSurfaceRateLimit( + request, + "notification-action", + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { token } = await context.params; + const verdict = verifyActionToken(token); + if (!verdict.ok) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: verdict.reason }, + }); + return verdict.reason === "expired" + ? apiError("This answer has expired", 410, { + errorCode: "notification.action.expired", + }) + : apiError("Invalid action", 401, { + errorCode: "notification.action.invalid", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: verdict.userId }, + select: { id: true, timezone: true }, + }); + if (!user) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "unknown_user" }, + }); + return apiError("Invalid action", 401, { + errorCode: "notification.action.invalid", + }); + } + getEvent()?.setAuth({ + user_id: user.id, + auth_method: "notification_action_token", + }); + + if (!(await isModuleEnabled(user.id, "mood"))) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "module_disabled" }, + }); + return apiError("Mood tracking is turned off", 403, { + errorCode: "notification.action.module_disabled", + }); + } + + const alreadyUsed = () => { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "already_used" }, + }); + return apiError("This reminder was already answered", 409, { + errorCode: "notification.action.already_used", + }); + }; + + const claimed = await claimActionNonce({ + userId: user.id, + nonce: verdict.nonce, + expiry: verdict.expiry, + }); + if (!claimed) return alreadyUsed(); + + const score = MOOD_QUICK_ACTIONS[verdict.action]; + let created: boolean; + try { + const result = await logTelegramMood({ + userId: user.id, + score, + tz: user.timezone, + externalId: quickActionExternalId(verdict.nonce), + source: "NOTIFICATION", + }); + created = result.created; + } catch (err) { + // Two taps raced past the existence check; the unique key let one in. + if (!isUniqueViolation(err)) { + // Nothing was written: give the answer back so a retry can land. + await releaseActionNonce({ userId: user.id, nonce: verdict.nonce }); + throw err; + } + created = false; + } + + if (!created) return alreadyUsed(); + + annotate({ + action: { name: "notification.action.mood_logged" }, + meta: { action: verdict.action }, + }); + return apiSuccess({ logged: true, action: verdict.action }); + }, +); diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 07cb60d32..efaf97c1d 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -41,7 +41,12 @@ const { vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); -vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock })); +vi.mock("@/lib/oura/client", async (importOriginal) => ({ + isAcceptedOuraIssuer: ( + await importOriginal() + ).isAcceptedOuraIssuer, + exchangeCode: exchangeMock, +})); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, })); @@ -65,10 +70,12 @@ function makeReq(opts: { code?: string; state?: string; cookie?: string; + iss?: string; }): NextRequest { const params = new URLSearchParams(); if (opts.code) params.set("code", opts.code); if (opts.state) params.set("state", opts.state); + if (opts.iss) params.set("iss", opts.iss); return { url: `https://app.example/api/oura/callback?${params}`, cookies: { @@ -127,10 +134,11 @@ describe("GET /api/oura/callback", () => { const res = await GET( makeReq({ code: "auth-code", state: "S", cookie: "S" }), ); - expect(exchangeMock).toHaveBeenCalledWith("auth-code", { - clientId: "c", - clientSecret: "s", - }); + expect(exchangeMock).toHaveBeenCalledWith( + "auth-code", + { clientId: "c", clientSecret: "s" }, + null, + ); const data = userUpdate.mock.calls[0]![0].data; expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); @@ -139,6 +147,34 @@ describe("GET /api/oura/callback", () => { }); }); +// Oura's current authorization server names itself on the callback +// (RFC 9207); the issuer travels on to pick the token endpoint. +describe("GET /api/oura/callback — issuer", () => { + it("passes Oura's issuer on to the code exchange", async () => { + const iss = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S", iss })); + expect(exchangeMock).toHaveBeenCalledWith( + "c", + { clientId: "c", clientSecret: "s" }, + iss, + ); + expect(res.headers.get("location")).toContain("oura=connected"); + }); + + it("refuses a callback that names a foreign authorization server", async () => { + const res = await GET( + makeReq({ + code: "c", + state: "S", + cookie: "S", + iss: "https://evil.example/oauth", + }), + ); + expect(res.headers.get("location")).toContain("reason=issuer"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); +}); + /** * v1.32.28 — connecting starts a sync straight away. The poll handler has * accepted a single-user payload since it was written; this callback is the diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 553dd9c64..89f4284ba 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -4,7 +4,7 @@ import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode } from "@/lib/oura/client"; +import { exchangeCode, isAcceptedOuraIssuer } from "@/lib/oura/client"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, @@ -67,11 +67,20 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) return ERR("nocode"); + // RFC 9207: an authorization server that names itself must be Oura's. The + // issuer also tells which token endpoint the code belongs to. Older apps + // send none, which is fine. + const iss = searchParams.get("iss"); + if (!isAcceptedOuraIssuer(iss)) { + annotate({ meta: { reason: "issuer" } }); + return ERR("issuer"); + } + try { const creds = await getOuraClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, creds, iss); await prisma.user.update({ where: { id: userId }, diff --git a/src/app/api/search/__tests__/route.test.ts b/src/app/api/search/__tests__/route.test.ts new file mode 100644 index 000000000..479e3598f --- /dev/null +++ b/src/app/api/search/__tests__/route.test.ts @@ -0,0 +1,299 @@ +/** + * `POST /api/search` — admission, validation, the rate limit, one search in + * flight per actor, and what the route hands the search and the log. + * + * The search itself is `searchRecords`, covered by its own unit tests and by + * `tests/integration/record-search.test.ts` against Postgres; here it is a + * spy, so the test can assert exactly what the route passes it. + * + * Mutation checks (each run, each seen red): + * - admit on `("read", "profile")` instead of the whole record → "admits + * the whole record only" goes red; + * - drop `.min(SEARCH_QUERY_MIN)` → "refuses a one-letter query" goes red; + * - put the query into the annotation → "never logs the query" goes red; + * - pass `actor.id` as the record → "searches the record the session acts + * for" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireRecordAuth: vi.fn(), + checkRateLimit: vi.fn(), + searchRecords: vi.fn(), + annotate: vi.fn(), + resolveModuleMap: vi.fn(), + domainVisible: vi.fn(() => true), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireRecordAuth: mocks.requireRecordAuth, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: mocks.annotate, + getEvent: () => undefined, +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: mocks.resolveModuleMap, +})); +vi.mock("@/lib/sharing/acting-domains", () => ({ + actingDomainVisibility: vi.fn(async () => mocks.domainVisible), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/search/search-records", () => ({ + searchRecords: mocks.searchRecords, +})); + +import { POST } from "../route"; +import { searchesInFlight } from "@/lib/search/in-flight"; + +const handler = POST as unknown as (req: NextRequest) => Promise; + +/** + * A search request. Written as the query string the route used to take, so + * every case reads as before; the route receives it as a JSON body. + */ +function request(query: string, init: { signal?: AbortSignal } = {}) { + const params = new URLSearchParams(query); + const body: Record = {}; + for (const [key, value] of params) { + body[key] = key === "limit" && /^\d+$/.test(value) ? Number(value) : value; + } + return new NextRequest("http://localhost/api/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: init.signal, + }); +} + +const OWNER = { id: "owner-1", locale: "de" }; +const ACTOR = { id: "actor-1", locale: "en" }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.requireRecordAuth.mockResolvedValue({ + user: OWNER, + actor: ACTOR, + grantId: "grant-1", + }); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.resolveModuleMap.mockResolvedValue({ illness: false }); + mocks.searchRecords.mockResolvedValue({ + groups: [ + { + kind: "visit", + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Schulter", marks: [[0, 8]] }, + context: [], + snippet: null, + day: "2026-01-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + more: false, + }, + ], + truncated: false, + stats: { sources: 12, rows: 40, candidates: 1, elapsedMs: 3 }, + }); +}); + +describe("POST /api/search", () => { + it("admits the whole record only, at read", async () => { + await handler(request("q=schulter")); + // A POST that only reads, declared as such. + expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record", { + readOverPost: true, + }); + }); + + it("propagates a refused admission without searching", async () => { + mocks.requireRecordAuth.mockRejectedValueOnce(new Error("denied")); + await expect(handler(request("q=schulter"))).rejects.toThrow("denied"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("searches the record the session acts for, with its masks", async () => { + const res = await handler(request("q=Schulter&limit=7")); + expect(res.status).toBe(200); + expect(mocks.searchRecords).toHaveBeenCalledTimes(1); + const args = mocks.searchRecords.mock.calls[0][0]; + expect(args.recordId).toBe("owner-1"); + expect(args.query).toBe("Schulter"); + expect(args.limit).toBe(7); + expect(args.modules).toEqual({ illness: false }); + expect(args.domainVisible).toBe(mocks.domainVisible); + expect(args.timezone).toBe("Europe/Berlin"); + // Labels follow the reader, not the record. + expect(args.displayLocale).toBe("en"); + const body = await res.json(); + expect(body.data.groups[0].hits[0].path).toBe("/checkups?visit=v1"); + expect(body.data.truncated).toBe(false); + expect(body.data.stats).toBeUndefined(); + }); + + it("defaults the limit", async () => { + await handler(request("q=knie")); + expect(mocks.searchRecords.mock.calls[0][0].limit).toBe(5); + }); + + it.each([ + ["a one-letter query", "q=a"], + ["a blank query", "q=%20%20%20"], + ["a missing query", "limit=5"], + ["an over-long query", `q=${"x".repeat(101)}`], + ["a zero limit", "q=knie&limit=0"], + ["a limit above the cap", "q=knie&limit=21"], + ["a non-numeric limit", "q=knie&limit=lots"], + ["a fractional limit", "q=knie&limit=2.5"], + ])("refuses %s with 422 and never searches", async (_label, query) => { + const res = await handler(request(query)); + expect(res.status).toBe(422); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("does not echo a refused query", async () => { + const secret = "Psychotherapie".repeat(8); + const res = await handler(request(`q=${secret}`)); + expect(res.status).toBe(422); + expect(await res.text()).not.toContain("Psychotherapie"); + }); + + it("rate limits per actor before reading anything", async () => { + mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false }); + const res = await handler(request("q=schulter")); + expect(res.status).toBe(429); + expect(mocks.checkRateLimit.mock.calls[0][0]).toBe("search:actor-1"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("never logs the query, only its length and the counts", async () => { + await handler(request("q=Psychotherapie")); + const logged = JSON.stringify(mocks.annotate.mock.calls); + expect(logged).not.toMatch(/psychotherapie/i); + const run = mocks.annotate.mock.calls.find( + ([e]) => e.action.name === "search.query.run", + ); + expect(run?.[0].meta).toMatchObject({ + query_length: 14, + hits: 1, + groups: 1, + truncated: false, + delegated: true, + }); + }); + + it("keeps the query out of the URL", async () => { + const req = request("q=Psychotherapie"); + expect(req.url).not.toMatch(/psychotherapie/i); + const res = await handler(req); + expect(res.status).toBe(200); + expect(mocks.searchRecords.mock.calls[0][0].query).toBe("Psychotherapie"); + }); + + it("refuses a body that is not JSON", async () => { + const res = await handler( + new NextRequest("http://localhost/api/search", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "q=schulter", + }), + ); + expect(res.status).toBe(415); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("allows 20 searches a minute per actor", async () => { + await handler(request("q=schulter")); + expect(mocks.checkRateLimit).toHaveBeenCalledWith( + "search:actor-1", + 20, + 60_000, + ); + }); + + it("hands the search an abort signal that follows the client", async () => { + const client = new AbortController(); + let seen: AbortSignal | undefined; + mocks.searchRecords.mockImplementationOnce(async (args) => { + seen = args.signal; + client.abort(); + return { + groups: [], + truncated: true, + stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 }, + }; + }); + await handler(request("q=schulter", { signal: client.signal })); + expect(seen?.aborted).toBe(true); + }); + + it("stops an actor's earlier search when a newer one starts", async () => { + let release: () => void = () => {}; + let firstSignal: AbortSignal | undefined; + mocks.searchRecords.mockImplementationOnce( + (args) => + new Promise((resolve) => { + firstSignal = args.signal; + release = () => + resolve({ + groups: [], + truncated: true, + stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 }, + }); + }), + ); + const first = handler(request("q=schul")); + await vi.waitFor(() => expect(firstSignal).toBeDefined()); + expect(firstSignal?.aborted).toBe(false); + + const second = await handler(request("q=schulter")); + expect(second.status).toBe(200); + expect(firstSignal?.aborted).toBe(true); + + release(); + const superseded = await first; + expect(superseded.status).toBe(409); + expect((await superseded.json()).meta?.errorCode).toBe("search.superseded"); + expect(searchesInFlight()).toBe(0); + }); + + it("does not stop another actor's search", async () => { + let firstSignal: AbortSignal | undefined; + let release: () => void = () => {}; + mocks.searchRecords.mockImplementationOnce( + (args) => + new Promise((resolve) => { + firstSignal = args.signal; + release = () => + resolve({ + groups: [], + truncated: false, + stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 }, + }); + }), + ); + const first = handler(request("q=schul")); + await vi.waitFor(() => expect(firstSignal).toBeDefined()); + + mocks.requireRecordAuth.mockResolvedValueOnce({ + user: { id: "owner-2", locale: "de" }, + actor: { id: "actor-2", locale: "de" }, + grantId: null, + }); + await handler(request("q=knie")); + expect(firstSignal?.aborted).toBe(false); + release(); + expect((await first).status).toBe(200); + }); +}); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 000000000..0ffcd61df --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,143 @@ +/** + * `POST /api/search` with `{ q, limit }` — one search over the record + * (Discussion #1117): + * visits and procedures, conditions, documents (title and indexed text), + * vaccinations, lab results, medications, symptoms, workouts and the notes on + * mood entries, readings and illness days. Each hit carries the in-app path + * that opens it. The work is `searchRecords` in `src/lib/search/`. + * + * Admission declares the whole record, like the timeline: the search lays + * every section side by side, so a grant scoped to some of them does not open + * it. The sources are narrowed to the grant's sections all the same + * (`actingDomainVisibility`), and to the record's module switches, so a + * switched-off module is not read at all. + * + * The query is health data ("Schulter", a drug, a diagnosis). It never + * reaches a log: it travels in the JSON body rather than the URL, so neither + * this app's request log nor a reverse proxy's access log records it; the + * annotation carries its length and the counts only, and a refused query is + * answered without echoing it. + * + * Bounded per actor: 20 searches a minute (the palette asks once per pause in + * typing, and a manager searching someone's record spends their own + * allowance), and one at a time. A newer search by the same actor aborts the + * one before it, and a client that goes away aborts its own, so an abandoned + * search stops reading at its next budget check instead of running out its + * time (`in-flight.ts`). + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { beginSearch, SUPERSEDED } from "@/lib/search/in-flight"; +import { searchRecords } from "@/lib/search/search-records"; +import { searchQuerySchema } from "@/lib/search/wire-schemas"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +/** Searches per actor per minute: one per pause in typing, with room to spare. */ +const SEARCH_RATE_LIMIT = 20; +const SEARCH_RATE_WINDOW_MS = 60_000; +/** The body is a query of at most 100 characters and a number. */ +const SEARCH_BODY_MAX_BYTES = 2048; + +function displayLocale(value: string | null | undefined): Locale { + return locales.includes(value as Locale) ? (value as Locale) : defaultLocale; +} + +export const POST = apiHandler(async (request: NextRequest) => { + // A POST that only reads: the query rides the body to stay out of URLs, and + // a read grant on the whole record is what it needs, as it was over GET. + const { user, actor, grantId } = await requireRecordAuth("read", "record", { + readOverPost: true, + }); + + const rl = await checkRateLimit( + `search:${actor.id}`, + SEARCH_RATE_LIMIT, + SEARCH_RATE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many searches. Please retry shortly.", 429); + } + + const { data: body, error: jsonError } = await safeJson<{ + q?: unknown; + limit?: unknown; + }>(request, { maxBytes: SEARCH_BODY_MAX_BYTES }); + if (jsonError) return jsonError; + const parsed = searchQuerySchema.safeParse({ + q: body?.q ?? undefined, + limit: body?.limit ?? undefined, + }); + if (!parsed.success) { + annotate({ + action: { name: "search.query.invalid" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "search.invalid", + }); + } + + const slot = beginSearch(actor.id, user.id); + try { + const [modules, domainVisible, timezone] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const result = await searchRecords({ + recordId: user.id, + query: parsed.data.q, + limit: parsed.data.limit, + modules, + domainVisible, + displayLocale: displayLocale(actor.locale), + timezone, + signal: AbortSignal.any([request.signal, slot.signal]), + }); + + if (slot.signal.aborted) { + // A newer search by the same actor took over; this answer is partial + // and nobody is waiting for it. + annotate({ + action: { name: "search.query.superseded" }, + meta: { query_length: parsed.data.q.length }, + }); + return apiError("A newer search replaced this one", 409, { + errorCode: SUPERSEDED, + }); + } + + annotate({ + action: { name: "search.query.run" }, + meta: { + query_length: parsed.data.q.length, + limit: parsed.data.limit, + sources: result.stats.sources, + rows_scanned: result.stats.rows, + matches: result.stats.candidates, + groups: result.groups.length, + hits: result.groups.reduce((n, g) => n + g.hits.length, 0), + truncated: result.truncated, + elapsed_ms: result.stats.elapsedMs, + delegated: grantId !== null, + aborted: request.signal.aborted, + }, + }); + return apiSuccess({ groups: result.groups, truncated: result.truncated }); + } finally { + slot.end(); + } +}); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 43c379a9d..28d268df6 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -163,6 +163,8 @@ interface IntakeUpsert { takenAt: string | null; skipped: boolean; source: string; + /** v1.42.2 — recorded by the server (`autoMarkTaken`), not the person. */ + autoRecorded: boolean; syncVersion: number; updatedAt: string; } @@ -389,6 +391,7 @@ export const GET = apiHandler(async (request: NextRequest) => { takenAt: true, skipped: true, source: true, + autoRecorded: true, syncVersion: true, deletedAt: true, updatedAt: true, @@ -543,6 +546,7 @@ export const GET = apiHandler(async (request: NextRequest) => { takenAt: row.takenAt?.toISOString() ?? null, skipped: row.skipped, source: row.source, + autoRecorded: row.autoRecorded, syncVersion: row.syncVersion, updatedAt: row.updatedAt.toISOString(), }); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index 77a2197cd..e97672d7c 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -69,6 +69,14 @@ export default function CheckupsPageClient() { const [view, setView] = useState( linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", ); + // A link to a visit can arrive while the page is already open (the search + // opens one from wherever the person is): it moves to the visits tab then + // too, rather than only on the first render. + const [observedVisit, setObservedVisit] = useState(linkedVisit); + if (linkedVisit !== observedVisit) { + setObservedVisit(linkedVisit); + if (linkedVisit) setView("visits"); + } const refresh = useCallback( () => diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 7fd88566d..aa1593d01 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -125,6 +125,8 @@ interface Medication { * and left out of the take-all and log-dose flows. */ trackIntake?: boolean; + /** v1.42.2 — doses recorded automatically. */ + autoMarkTaken?: boolean; /** v1.39.4 (#1040) — server-resolved: taken / skip are offered today. */ intakeActionable?: boolean; /** v1.39.4 (#1040) — where today sits in the course. */ diff --git a/src/app/mood/__tests__/add-param-record-scope.test.tsx b/src/app/mood/__tests__/add-param-record-scope.test.tsx new file mode 100644 index 000000000..684dc743c --- /dev/null +++ b/src/app/mood/__tests__/add-param-record-scope.test.tsx @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +/** + * `/mood?add=1` is the mood reminder's tap target. It opens the entry sheet + * only in the caller's own record: a session that is inside a managed + * profile or a shared record opens the page without the sheet, so a reminder + * meant for the recipient never becomes a write form on somebody else. + */ + +const caps = { + current: { + inSharedRecord: false, + recordSessionPending: undefined as boolean | undefined, + accessRefused: undefined as boolean | undefined, + canManageDomain: (_d: string) => true, + }, +}; +const params = { current: new URLSearchParams("add=1") }; + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => caps.current, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { modules: {} }, + isAuthenticated: true, + isLoading: false, + }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-pull-to-refresh", () => ({ + usePullToRefresh: () => ({}), +})); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: () => {} }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: () => {}, push: () => {} }), + useSearchParams: () => params.current, +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ open }: { open: boolean; children?: ReactNode }) => + open ?
: null, +})); +vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null })); +vi.mock("@/components/mood/mood-list", () => ({ MoodList: () => null })); +vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({ + PullToRefreshIndicator: () => null, +})); + +import MoodPageClient from "../page-client"; + +function sheetOpen(): boolean { + return renderToStaticMarkup().includes( + 'data-slot="mood-sheet-open"', + ); +} + +beforeEach(() => { + params.current = new URLSearchParams("add=1"); + caps.current = { + inSharedRecord: false, + recordSessionPending: undefined, + accessRefused: undefined, + canManageDomain: () => true, + }; +}); + +describe("/mood ?add=1 and the record on screen", () => { + it("opens the entry sheet in the caller's own record", () => { + expect(sheetOpen()).toBe(true); + }); + + it("keeps the sheet closed without the param", () => { + params.current = new URLSearchParams(); + expect(sheetOpen()).toBe(false); + }); + + it("does not open the sheet inside a managed profile the caller manages", () => { + // A guardian switched into a managed profile holds MANAGE on mind, so + // the sheet would otherwise open and save onto that profile. + caps.current = { ...caps.current, inSharedRecord: true }; + expect(sheetOpen()).toBe(false); + }); + + it("does not open the sheet while the record context is pending or refused", () => { + caps.current = { + ...caps.current, + inSharedRecord: true, + recordSessionPending: true, + canManageDomain: () => false, + }; + expect(sheetOpen()).toBe(false); + caps.current = { + ...caps.current, + recordSessionPending: undefined, + accessRefused: true, + }; + expect(sheetOpen()).toBe(false); + }); +}); diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index 4bf441c6b..5891e6a15 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -15,19 +15,58 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { ArrowRight, Plus, Wrench } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useTranslations } from "@/lib/i18n/context"; export default function MoodPageClient() { const { user, isAuthenticated, isLoading } = useAuth(); - const { canManageDomain, inSharedRecord } = useRecordCapabilities(); + const { + canManageDomain, + inSharedRecord, + recordSessionPending, + accessRefused, + } = useRecordCapabilities(); // A mood entry is created at MANAGE under the mind section. const canAddMood = canManageDomain("mind"); const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + // `?add=1` opens the entry sheet on the first paint: the mood reminder's + // tap target (`tap-target.ts`). Read in the lazy initialiser so SSR and the + // hydration render agree; the effect below strips the one-shot param so the + // back button does not reopen the sheet. A later client-side navigation + // back onto `?add=1` is handled render-side, the measurements-page pattern. + // + // The request is held apart from the sheet's own open state because it is + // honoured only in the caller's own record. A reminder is about the person + // who receives it; a session that is currently inside a managed profile or + // a shared record would otherwise open a write form there, and an entry + // saved from it lands on the wrong person. In that case the page opens + // without the sheet and the shell's shared-record banner offers the way + // back. The answer is read once the account query settled (`isLoading` + // gates the content render), so the request is dropped, not deferred: a + // later switch back to the own record must not pop a stale sheet. + const addParam = searchParams.get("add") === "1"; const [dialogOpen, setDialogOpen] = useState(false); + const [addRequested, setAddRequested] = useState(() => addParam); + const [consumedAdd, setConsumedAdd] = useState(() => addParam); + if (!addParam && consumedAdd) { + setConsumedAdd(false); + } else if (addParam && !consumedAdd) { + setConsumedAdd(true); + setAddRequested(true); + } + const ownRecord = !inSharedRecord && !recordSessionPending && !accessRefused; + if (addRequested && mounted && !isLoading && !ownRecord) { + setAddRequested(false); + } + const sheetOpen = (dialogOpen || (addRequested && ownRecord)) && canAddMood; + const setSheetOpen = useCallback((open: boolean) => { + setDialogOpen(open); + if (!open) setAddRequested(false); + }, []); // The list's empty state carries the add action while there is nothing // to list, so the header drops its own then. const [listEmpty, setListEmpty] = useState(false); @@ -54,9 +93,17 @@ export default function MoodPageClient() { ); const pull = usePullToRefresh({ onRefresh: refreshVisible, - disabled: dialogOpen, + disabled: sheetOpen, }); + useEffect(() => { + if (!searchParams.has("add")) return; + const next = new URLSearchParams(searchParams.toString()); + next.delete("add"); + const search = next.toString(); + router.replace(search ? `/mood?${search}` : "/mood"); + }, [searchParams, router]); + useEffect(() => { if (isLoading) return; if (!isAuthenticated) { @@ -127,14 +174,14 @@ export default function MoodPageClient() { /> } > setDialogOpen(false)} - onCancel={() => setDialogOpen(false)} + onSuccess={() => setSheetOpen(false)} + onCancel={() => setSheetOpen(false)} footerSlot={footerEl} /> diff --git a/src/components/admin/__tests__/provider-health-spend-today.test.tsx b/src/components/admin/__tests__/provider-health-spend-today.test.tsx index bfe056560..5b334023d 100644 --- a/src/components/admin/__tests__/provider-health-spend-today.test.tsx +++ b/src/components/admin/__tests__/provider-health-spend-today.test.tsx @@ -42,24 +42,26 @@ function render(data: object | null) { } describe(" — today's spend by cost owner", () => { - it("shows both figures and the day they belong to", () => { + it("shows both figures, the account count, and which day they count", () => { const html = render({ providers: [], spendToday: { - dateKey: "2026-09-11", totalTokens: 1_240_000, operatorTokens: 151_200, + accounts: 3, }, }); - expect(html).toContain("2026-09-11"); expect(html).toContain("1,240,000"); expect(html).toContain("151,200"); + expect(html).toContain("accounts with spend: 3"); + // Each account's own day, not one date for the whole instance. + expect(html).toContain("each account's own calendar day"); }); it("renders a zero day rather than hiding the readout", () => { const html = render({ providers: [], - spendToday: { dateKey: "2026-09-11", totalTokens: 0, operatorTokens: 0 }, + spendToday: { totalTokens: 0, operatorTokens: 0, accounts: 0 }, }); expect(html).toContain('data-slot="ai-spend-today"'); }); diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index c0e0c066e..c54a88b65 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -34,10 +34,11 @@ import { formatDateTime } from "@/lib/format"; * the users' own plans. The operator ceiling is enforced against the second * figure, so both belong on screen. */ +/** Summed over every account's own current day, the day its ceilings use. */ interface SpendToday { - dateKey: string; totalTokens: number; operatorTokens: number; + accounts: number; } interface ProviderHealthRow { @@ -107,7 +108,7 @@ export function ProviderHealthSection() { {spendToday ? (

{t("admin.providerHealth.spendToday", { - date: spendToday.dateKey, + accounts: groupDigits(spendToday.accounts), total: groupDigits(spendToday.totalTokens), operator: groupDigits(spendToday.operatorTokens), })} diff --git a/src/components/charts/__tests__/health-chart-day-links.test.tsx b/src/components/charts/__tests__/health-chart-day-links.test.tsx index 0e944731d..6c68fed61 100644 --- a/src/components/charts/__tests__/health-chart-day-links.test.tsx +++ b/src/components/charts/__tests__/health-chart-day-links.test.tsx @@ -52,11 +52,10 @@ describe(" day links", () => { vi.resetModules(); }); - it("a metric page's chart opens its days: plot, caption and table", async () => { + it("a metric page's chart opens its days: plot and table", async () => { const html = await renderChart({ dayLinks: true, showDataTable: true }); expect(html).toContain('data-day-links="true"'); - expect(html).toContain('data-slot="chart-day-caption"'); - expect(html).toContain("Click a point to open its day."); + expect(html).not.toContain('data-slot="chart-day-caption"'); const links = html.match(/data-slot="day-link"/g) ?? []; expect(links).toHaveLength(10); expect(html).toMatch(/href="[^"]*\?day=\d{4}-\d{2}-\d{2}"/); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 67bad9159..d25956a3c 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,6 +1,5 @@ "use client"; -import { ChartDayCaption } from "@/components/day/chart-day"; import { dayAnchor } from "@/components/day/chart-day-links"; import { OPEN_DAY_CELL, useHeatmapDay } from "@/components/day/heatmap-day"; import { useCalendarDate } from "@/hooks/use-calendar-date"; @@ -368,7 +367,6 @@ export function ComplianceHeatmap({

{activeCells.length > 0 ? ( <> - {/* The keyboard and screen-reader way to each active day's doses, and to the day itself. */}
-
@@ -411,7 +408,6 @@ export function MoodHeatmap({
{loggedCells.length > 0 ? ( <> - {/* The keyboard and screen-reader way to each logged day's value, and to the day itself. */} ({ timestamp: dayAnchor(p.day) }))} axis="band" - mark="bar" // The y axis (44) on the left, the margin (12) on the right. insetLeft={44} insetRight={12} diff --git a/src/components/command-palette/__tests__/command-palette.test.tsx b/src/components/command-palette/__tests__/command-palette.test.tsx index cd23ee416..aeeb64b5f 100644 --- a/src/components/command-palette/__tests__/command-palette.test.tsx +++ b/src/components/command-palette/__tests__/command-palette.test.tsx @@ -4,6 +4,7 @@ import { Activity, Moon, Plus, Smile } from "lucide-react"; import { I18nProvider } from "@/lib/i18n/context"; import type { PaletteEntry } from "@/lib/command-palette/build-index"; +import type { SearchResponse } from "@/lib/search/contract"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), @@ -69,6 +70,18 @@ vi.mock("../use-palette-index", () => ({ usePaletteIndex: () => ENTRIES, })); +const recordSearch = vi.hoisted(() => ({ + state: { + query: null as string | null, + data: undefined as SearchResponse | undefined, + loading: false, + error: false, + }, +})); +vi.mock("../use-record-search", () => ({ + useRecordSearch: () => recordSearch.state, +})); + import { PaletteBody, arrangeResults } from "../command-palette"; function render(isMobile = false) { @@ -122,7 +135,9 @@ describe("", () => { const html = render(); expect(html).toContain('role="group"'); expect(html).toContain('aria-label="Search"'); - expect(html).toContain('placeholder="Search pages, settings, modules…"'); + expect(html).toContain( + 'placeholder="Search pages, settings, your records…"', + ); expect(html).toContain(">Actions<"); }); @@ -133,3 +148,104 @@ describe("", () => { expect(phone).toContain(">Cancel<"); }); }); + +const HITS: SearchResponse = { + groups: [ + { + kind: "visit", + more: false, + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Arthroskopie", marks: [] }, + context: [{ text: "Schulter (links)", marks: [[0, 8]] }], + snippet: null, + day: "2026-03-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + }, + { + kind: "note", + more: false, + hits: [ + { + id: "moodNote:m1", + kind: "note", + title: null, + context: [], + snippet: { text: "Die SCHULTER zwickt", marks: [[7, 15]] }, + day: "2026-03-05", + path: "/mood?day=2026-03-05", + source: "mood", + }, + ], + }, + ], + truncated: true, +}; + +describe(" in your records", () => { + it("lists the record hits after the local results as options", () => { + recordSearch.state = { + query: "schulter", + data: HITS, + loading: false, + error: false, + }; + const html = render(); + expect(html).toContain('data-group="records"'); + expect(html).toContain(">In your records<"); + expect(html).toContain('data-entry="record:visit:v1"'); + expect(html).toContain('data-entry="record:moodNote:m1"'); + // After every local option. + expect(html.indexOf('data-entry="action:today"')).toBeLessThan( + html.indexOf('data-entry="record:visit:v1"'), + ); + // The matched part is marked, as text. + expect(html).toContain("Schulter (links)"); + // A note without a name is called by what it was written on. + expect(html).toContain("Mood note"); + // Record text is never parsed as markup. + expect(html).not.toContain(""); + expect(html).toContain("<b>"); + expect(html).toContain("Only the most recent entries were searched."); + }); + + it("says when it is searching, and when nothing matched", () => { + recordSearch.state = { + query: "schulter", + data: undefined, + loading: true, + error: false, + }; + expect(render()).toContain("Searching your records…"); + recordSearch.state = { + query: "schulter", + data: { groups: [], truncated: false }, + loading: false, + error: false, + }; + expect(render()).toContain("Nothing in your records for “schulter”."); + recordSearch.state = { + query: "schulter", + data: undefined, + loading: false, + error: true, + }; + expect(render()).toContain("Your records could not be searched."); + }); + + it("shows nothing of the records while no search applies", () => { + recordSearch.state = { + query: null, + data: undefined, + loading: false, + error: false, + }; + expect(render()).not.toContain('data-group="records"'); + }); +}); diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index f3d97849f..14b19727f 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -36,10 +36,13 @@ import { pushRecent, readRecent } from "@/lib/command-palette/recent"; import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useTranslations } from "@/lib/i18n/context"; +import type { SearchHit } from "@/lib/search/contract"; import { cn } from "@/lib/utils"; import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store"; +import { RecordHitContent } from "./record-hit"; import { usePaletteIndex } from "./use-palette-index"; +import { useRecordSearch } from "./use-record-search"; /** * The command palette: one field, and below it the places, settings, module @@ -47,6 +50,11 @@ import { usePaletteIndex } from "./use-palette-index"; * Escape closes. A module's entry is a switch: Enter flips it and the palette * stays open. * + * After the local results comes "In your records": the server's search over + * the record (`useRecordSearch`, `POST /api/search`), once the typing pauses. + * Its hits are options like any other, after the local ones, so the arrows + * and Enter work across both; each opens the page that shows the record. + * * Accessibility follows the combobox pattern: the field is the * `role="combobox"` that keeps focus, the results are a `listbox` of grouped * `option`s, and `aria-activedescendant` names the highlighted one. @@ -177,8 +185,15 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { () => sections.flatMap((section) => section.entries), [sections], ); - const activeIndex = - flat.length === 0 ? -1 : Math.min(active, flat.length - 1); + const records = useRecordSearch(query); + const recordHits = useMemo( + () => records.data?.groups.flatMap((group) => group.hits) ?? [], + [records.data], + ); + const showRecords = records.query !== null; + const shownHits = showRecords ? recordHits : []; + const total = flat.length + shownHits.length; + const activeIndex = total === 0 ? -1 : Math.min(active, total - 1); const optionId = (i: number) => `${listId}-option-${i}`; // Keep the highlighted option in view as the arrows move through a list @@ -231,19 +246,33 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { } } + function openRecord(path: string) { + setCommandPaletteOpen(false); + router.push(path); + } + + function runAt(index: number) { + if (index < 0) return; + if (index < flat.length) { + run(flat[index]); + return; + } + const hit = shownHits[index - flat.length]; + if (hit) openRecord(hit.path); + } + function onKeyDown(event: React.KeyboardEvent) { if (event.nativeEvent.isComposing) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - if (flat.length === 0) return; + if (total === 0) return; const step = event.key === "ArrowDown" ? 1 : -1; - setActive((activeIndex + step + flat.length) % flat.length); + setActive((activeIndex + step + total) % total); return; } if (event.key === "Enter") { event.preventDefault(); - const entry = flat[activeIndex]; - if (entry) run(entry); + runAt(activeIndex); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { @@ -278,7 +307,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { autoFocus type="text" role="combobox" - aria-expanded={flat.length > 0} + aria-expanded={total > 0} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={ @@ -319,7 +348,9 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { data-slot="command-palette-results" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2" > - {sections.length === 0 ? ( + {sections.length === 0 && + (!showRecords || + (!records.loading && !records.error && shownHits.length === 0)) ? (

+ ) : null}

{isMobile ? null : ( @@ -444,3 +496,104 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { ); } + +/** + * "In your records": the server's hits after the local results. A status line + * (searching, nothing found, failed, older entries not read) is plain text, + * not an option, so the arrows skip it. + */ +function RecordSection({ + headingId, + hits, + firstIndex, + activeIndex, + optionId, + loading, + error, + truncated, + query, + onHover, + onOpen, +}: { + headingId: string; + hits: ReadonlyArray; + firstIndex: number; + activeIndex: number; + optionId: (i: number) => string; + loading: boolean; + error: boolean; + truncated: boolean; + query: string; + onHover: (i: number) => void; + onOpen: (path: string) => void; +}) { + const { t } = useTranslations(); + const status = error + ? t("palette.records.error") + : hits.length === 0 + ? loading + ? t("palette.records.loading") + : t("palette.records.empty", { query }) + : null; + return ( +
+
+ {t("palette.groups.records")} +
+ {hits.map((hit, n) => { + const i = firstIndex + n; + const selected = i === activeIndex; + return ( +
{ + if (!selected) onHover(i); + }} + onMouseDown={(event) => { + // Keep focus in the field; the click opens the record. + event.preventDefault(); + }} + onClick={() => onOpen(hit.path)} + className={cn( + "group flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm", + selected && "bg-accent text-accent-foreground", + )} + > + +
+ ); + })} + {status ? ( +

+ {status} +

+ ) : null} + {truncated && !error ? ( +

+ {t("palette.records.truncated")} +

+ ) : null} +
+ ); +} diff --git a/src/components/command-palette/record-hit.tsx b/src/components/command-palette/record-hit.tsx new file mode 100644 index 000000000..5f2316300 --- /dev/null +++ b/src/components/command-palette/record-hit.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + Activity, + Dumbbell, + FileText, + FlaskConical, + NotebookPen, + Pill, + Stethoscope, + Syringe, + Thermometer, + type LucideIcon, +} from "lucide-react"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + SearchHit, + SearchHitKind, + SearchMarkedText, +} from "@/lib/search/contract"; + +export const RECORD_HIT_ICON: Record = { + visit: Stethoscope, + condition: Thermometer, + document: FileText, + vaccination: Syringe, + lab: FlaskConical, + medication: Pill, + symptom: Activity, + workout: Dumbbell, + note: NotebookPen, +}; + +/** + * Text with the ranges the query matched set in ``. Plain React text + * children only: the ranges are character offsets the server computed, and + * nothing here is parsed as markup. + */ +export function Marked({ value }: { value: SearchMarkedText }) { + const parts: React.ReactNode[] = []; + let at = 0; + for (const [start, end] of value.marks) { + if (start < at || end > value.text.length || end <= start) continue; + if (start > at) parts.push(value.text.slice(at, start)); + parts.push( + + {value.text.slice(start, end)} + , + ); + at = end; + } + if (at < value.text.length) parts.push(value.text.slice(at)); + return <>{parts}; +} + +/** What a hit is called when its record has no name of its own. */ +export function useRecordHitLabel(): (hit: SearchHit) => string { + const { t } = useTranslations(); + return (hit) => + hit.source + ? t(`palette.records.source.${hit.source}`) + : t(`palette.records.kind.${hit.kind}`); +} + +/** The body of one record option: title, kind and date, context, snippet. */ +export function RecordHitContent({ hit }: { hit: SearchHit }) { + const fmt = useFormatters(); + const label = useRecordHitLabel()(hit); + const Icon = RECORD_HIT_ICON[hit.kind]; + return ( + <> +