Merge pull request #1205 from MBombeck/release/v1.42.2

Release v1.42.2
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-11 16:10:10 +02:00
commit c6db4501f1
256 files changed
+14746 -1089

No files matched your search

+82
View File
@@ -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.
+4
View File
@@ -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
<https://docs.healthlog.dev>.
+425 -15
View File
@@ -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 (`<Class: 0x…>`, `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
+20
View File
@@ -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
+114
View File
@@ -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 /<topic>`.
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 <APP_URL>/api/notifications/action/<token>`. 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 |
+5 -2
View File
@@ -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.
+55
View File
@@ -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.
+19 -5
View File
@@ -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"]',
+112 -1
View File
@@ -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<string[]> {
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$/);
});
});
+1 -4
View File
@@ -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);
+19
View File
@@ -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<string>((resolve) =>
requestAnimationFrame(() =>
setTimeout(() => resolve(getComputedStyle(el).pointerEvents)),
),
),
),
)
.toBe("none");
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await expect(panel(page)).toBeVisible();
+39
View File
@@ -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,
}) => {
+80 -15
View File
@@ -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}`, {
+26 -2
View File
@@ -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<TimelineMockLog> {
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) =>
+70 -16
View File
@@ -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": {
+70 -16
View File
@@ -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": {
+70 -16
View File
@@ -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": {
+70 -16
View File
@@ -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": {
+70 -16
View File
@@ -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": {
+70 -16
View File
@@ -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": {
+70 -16
View File
@@ -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": {
+1 -1
View File
@@ -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",
@@ -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;
@@ -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);
+28 -1
View File
@@ -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).
+1 -1
View File
@@ -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/*`
@@ -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);
@@ -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);
});
});
@@ -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",
+5 -1
View File
@@ -1045,6 +1045,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
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<string, string> = {
* 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` —
+97
View File
@@ -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<string, string> = {
"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 =
/(?<![.\w])(?:NOW\(\)|CURRENT_TIMESTAMP\b|clock_timestamp\(\))|(?:(?<!=)[<>]|=)\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);
}
});
});
@@ -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,
},
@@ -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}`;
@@ -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);
});
+10 -11
View File
@@ -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,
},
});
});
+1 -1
View File
@@ -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,
@@ -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<Response>)(
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<Response>)(
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);
@@ -183,7 +183,7 @@ async function reserveFor(
providerType: ProviderChainType,
tokens: number,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const dateKey = await buildDateKey(userId);
const reservation = await reserveBudget(
userId,
tokens,
@@ -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,
@@ -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) => {
@@ -122,7 +122,7 @@ async function reserveFor(
userId: string,
providerType: ProviderChainType,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const dateKey = await buildDateKey(userId);
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentAssist.maxTokens,
@@ -216,7 +216,7 @@ async function reserveFor(
ask: SummaryRequest,
providerType: ProviderChainType,
): Promise<AiRunBudget | null> {
const dateKey = buildDateKey();
const dateKey = await buildDateKey(ask.userId);
const reservation = await reserveBudget(
ask.userId,
budgetFor(ask.mode),
@@ -195,6 +195,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
},
metadata: {
source: "deploy-webhook",
url: "/admin/system-status",
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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")
@@ -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,
}));
@@ -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.
+41 -2
View File
@@ -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,
},
}),
+5 -2
View File
@@ -225,7 +225,10 @@ async function handleExtract(request: NextRequest): Promise<Response> {
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<Response> {
// 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);
@@ -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.
+9 -1
View File
@@ -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).
+14 -1
View File
@@ -189,6 +189,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
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<Response> {
// 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<Response> {
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({
@@ -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<Parameters<typeof mintActionToken>[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",
}),
});
});
});
@@ -0,0 +1,171 @@
/**
* `POST /api/notifications/action/<token>` — 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 });
},
);
@@ -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<typeof import("@/lib/oura/client")>()
).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
+11 -2
View File
@@ -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 },
+299
View File
@@ -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<Response>;
/**
* 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<string, unknown> = {};
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);
});
});
+143
View File
@@ -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();
}
});
+4
View File
@@ -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(),
});
+8
View File
@@ -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(
() =>
+2
View File
@@ -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. */
@@ -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 ? <div data-slot="mood-sheet-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(<MoodPageClient />).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);
});
});
+54 -7
View File
@@ -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() {
/>
<ResponsiveSheet
open={dialogOpen}
onOpenChange={setDialogOpen}
open={sheetOpen}
onOpenChange={setSheetOpen}
title={t("dashboard.quickAddMood")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MoodForm
onSuccess={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}
onSuccess={() => setSheetOpen(false)}
onCancel={() => setSheetOpen(false)}
footerSlot={footerEl}
/>
</ResponsiveSheet>
@@ -42,24 +42,26 @@ function render(data: object | null) {
}
describe("<ProviderHealthSection> — 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&#x27;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"');
});
@@ -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 ? (
<p className="text-muted-foreground text-sm" data-slot="ai-spend-today">
{t("admin.providerHealth.spendToday", {
date: spendToday.dateKey,
accounts: groupDigits(spendToday.accounts),
total: groupDigits(spendToday.totalTokens),
operator: groupDigits(spendToday.operatorTokens),
})}
@@ -52,11 +52,10 @@ describe("<HealthChart> 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}"/);
@@ -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({
</div>
{activeCells.length > 0 ? (
<>
<ChartDayCaption coarse={heatmapDay.coarse} mark="cell" rug={false} />
{/* The keyboard and screen-reader way to each active day's doses,
and to the day itself. */}
<ChartDataTable
+1 -5
View File
@@ -1,6 +1,5 @@
"use client";
import { ChartDayCaption, useCoarsePointer } 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";
@@ -102,14 +101,13 @@ export function MoodHeatmapBody({
/**
* The loading shape of the stretched heatmap, at exactly the height the
* painted heatmap takes: the reserved grid box, the legend line, the day
* caption and the data-table toggle. `days` is the window when it is already
* data-table toggle. `days` is the window when it is already
* known; without it the shape assumes a window short enough to reach the cell
* ceiling on every width down to 360 px (eight weeks), the height most
* calendars settle at.
*/
export function MoodHeatmapSkeleton({ days = 56 }: { days?: number }) {
const timeZone = useDisplayTimezone();
const coarse = useCoarsePointer();
return (
<div
data-slot="mood-heatmap-skeleton"
@@ -120,7 +118,6 @@ export function MoodHeatmapSkeleton({ days = 56 }: { days?: number }) {
<div className="bg-muted/50 h-full w-full animate-pulse rounded-md motion-reduce:animate-none" />
</MoodHeatmapBody>
<div className="mt-2 h-4" />
<ChartDayCaption coarse={coarse} mark="cell" rug={false} />
<div className="border-border mt-3 border-t pt-3">
<div className="min-h-11" />
</div>
@@ -411,7 +408,6 @@ export function MoodHeatmap({
</div>
{loggedCells.length > 0 ? (
<>
<ChartDayCaption coarse={heatmapDay.coarse} mark="cell" rug={false} />
{/* The keyboard and screen-reader way to each logged day's value,
and to the day itself. */}
<ChartDataTable
@@ -173,7 +173,6 @@ export function NutrientDailyBarChart({
links={chartDays}
points={points.map((p) => ({ 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}
@@ -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("<PaletteBody>", () => {
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("<PaletteBody>", () => {
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 <b>SCHULTER</b> zwickt", marks: [[7, 15]] },
day: "2026-03-05",
path: "/mood?day=2026-03-05",
source: "mood",
},
],
},
],
truncated: true,
};
describe("<PaletteBody> 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("<mark");
expect(html).toContain(">Schulter</mark> (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("<b>");
expect(html).toContain("&lt;b&gt;");
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"');
});
});
@@ -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<HTMLInputElement>) {
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)) ? (
<p
data-slot="command-palette-empty"
className="text-muted-foreground px-3 py-8 text-center text-sm"
@@ -414,6 +445,27 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
);
})
)}
{showRecords &&
!(
sections.length === 0 &&
!records.loading &&
!records.error &&
shownHits.length === 0
) ? (
<RecordSection
headingId={`${listId}-group-records`}
hits={shownHits}
firstIndex={flat.length}
activeIndex={activeIndex}
optionId={optionId}
loading={records.loading}
error={records.error}
truncated={records.data?.truncated === true}
query={records.query ?? ""}
onHover={setActive}
onOpen={openRecord}
/>
) : null}
</div>
{isMobile ? null : (
@@ -444,3 +496,104 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
</div>
);
}
/**
* "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<SearchHit>;
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 (
<div
role="group"
aria-labelledby={headingId}
aria-busy={loading}
data-group="records"
className="pb-1"
>
<div
id={headingId}
className="text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase"
>
{t("palette.groups.records")}
</div>
{hits.map((hit, n) => {
const i = firstIndex + n;
const selected = i === activeIndex;
return (
<div
key={hit.id}
id={optionId(i)}
role="option"
aria-selected={selected}
data-entry={`record:${hit.id}`}
data-kind={hit.kind}
onMouseMove={() => {
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",
)}
>
<RecordHitContent hit={hit} />
</div>
);
})}
{status ? (
<p
data-slot="command-palette-records-status"
aria-live="polite"
className="text-muted-foreground px-3 py-2 text-xs"
>
{status}
</p>
) : null}
{truncated && !error ? (
<p
data-slot="command-palette-records-truncated"
className="text-muted-foreground px-3 pt-1 pb-2 text-xs"
>
{t("palette.records.truncated")}
</p>
) : null}
</div>
);
}
@@ -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<SearchHitKind, LucideIcon> = {
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 `<mark>`. 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(
<mark
key={start}
className="bg-primary/15 text-foreground rounded-[2px] font-medium"
>
{value.text.slice(start, end)}
</mark>,
);
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 (
<>
<Icon
className="text-muted-foreground mt-0.5 size-4 shrink-0 self-start"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate">
{hit.title ? <Marked value={hit.title} /> : label}
</span>
<span className="block truncate text-xs">
{/* Muted reads too faint on the highlighted row's accent fill, so
the highlighted option sets it in the accent's own ink. */}
<span className="text-muted-foreground group-aria-selected:text-accent-foreground">
{hit.title ? label : null}
{hit.title && hit.day ? " · " : null}
{hit.day ? fmt.dateShortSmartCalendar(hit.day) : null}
</span>
{hit.context.map((part, i) => (
<span key={i}>
{hit.title || hit.day || i > 0 ? " · " : null}
<Marked value={part} />
</span>
))}
</span>
{hit.snippet ? (
<span className="line-clamp-2 block text-xs break-words">
<Marked value={hit.snippet} />
</span>
) : null}
</span>
</>
);
}
@@ -0,0 +1,78 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
SEARCH_QUERY_MAX,
SEARCH_QUERY_MIN,
type SearchResponse,
} from "@/lib/search/contract";
/** Hits per group the palette asks for. */
export const PALETTE_RECORD_LIMIT = 4;
/** Pause in typing before the record search runs. */
const DEBOUNCE_MS = 200;
export interface RecordSearchState {
/** The query the shown answer belongs to, or null while none applies. */
query: string | null;
data: SearchResponse | undefined;
/**
* The answer for the typed text is not in yet (still typing, or the
* request in flight). `data` may hold the previous query's answer.
*/
loading: boolean;
error: boolean;
}
/**
* The palette's search over the record, after the local results.
*
* Runs once the typing pauses, for a query of at least two characters, and
* only where the server answers it: on the person's own record, or on a
* record shared whole. A grant over some sections never opens the search, so
* it is not asked. The request carries the query's abort signal: when the
* next keystroke changes the key, the previous query loses its observer and
* its request is cancelled rather than left to land late, and the server
* stops reading for it as well.
*/
export function useRecordSearch(rawQuery: string): RecordSearchState {
const { inSharedRecord, sections } = useRecordCapabilities();
const available = !inSharedRecord || sections === null;
const typed = rawQuery.trim().slice(0, SEARCH_QUERY_MAX);
const query = useDebouncedValue(typed, DEBOUNCE_MS);
const wanted = available && typed.length >= SEARCH_QUERY_MIN;
const enabled = wanted && query.length >= SEARCH_QUERY_MIN;
const result = useQuery({
queryKey: queryKeys.recordSearch(query, PALETTE_RECORD_LIMIT),
// A POST with the query in the body, so it never stands in a URL that a
// proxy's access log or the browser history could keep.
queryFn: ({ signal }) =>
apiPost<SearchResponse>(
"/api/search",
{ q: query, limit: PALETTE_RECORD_LIMIT },
{ signal },
),
enabled,
// The previous answer stays while the next one loads, so typing does not
// flash the section empty between keystrokes.
placeholderData: keepPreviousData,
staleTime: 30_000,
gcTime: 60_000,
retry: false,
});
// Still typing, or the answer for the typed text not in yet.
const current = typed === query && !result.isPlaceholderData;
return {
query: wanted ? typed : null,
data: wanted && enabled ? result.data : undefined,
loading: wanted && (!current || result.isFetching),
error: wanted && current && result.isError,
};
}
+9 -16
View File
@@ -6,12 +6,10 @@ import type { DateKey } from "@/lib/day/contract";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
ChartDayCaption,
DayRug,
TooltipDayAction,
useCoarsePointer,
type ChartDayAxis,
type ChartDayMark,
} from "./chart-day";
import { openDay, useOpenDay, type DayFocus } from "./day-layer-controller";
import { isOpenableDay } from "./day-url";
@@ -35,7 +33,7 @@ import { useTodayKey } from "./use-today-key";
* days by accident;
* - the open day is marked with `OPEN_DAY_LINE` (a dashed vertical line)
* at `openIndex`;
* - `ChartDayFooter` draws the row of day dots and the one-line caption.
* - `ChartDayFooter` draws the row of day dots.
*
* The caller says, per drawn point and in data order, which calendar day the
* point stands for, or `null` when it stands for none (a week or month
@@ -190,7 +188,7 @@ export function useChartDayLinks({
}
/**
* The row of day dots and the caption under a day-linked chart. Nothing when
* The row of day dots under a day-linked chart. Nothing when
* the chart is not day-linked or draws no points.
*/
export function ChartDayFooter({
@@ -199,7 +197,6 @@ export function ChartDayFooter({
axis = "index",
insetLeft,
insetRight,
mark = "point",
}: {
links: Pick<ChartDayLinks, "active" | "coarse"> &
Partial<Pick<ChartDayLinks, "days">>;
@@ -208,20 +205,16 @@ export function ChartDayFooter({
axis?: ChartDayAxis;
insetLeft: number;
insetRight: number;
mark?: ChartDayMark;
}) {
if (!links.active || points.length === 0) return null;
return (
<>
<DayRug
points={points}
pointDays={links.days}
axis={axis}
insetLeft={insetLeft}
insetRight={insetRight}
/>
<ChartDayCaption coarse={links.coarse} mark={mark} />
</>
<DayRug
points={points}
pointDays={links.days}
axis={axis}
insetLeft={insetLeft}
insetRight={insetRight}
/>
);
}
-38
View File
@@ -257,41 +257,3 @@ export function DayRug({
</div>
);
}
/**
* What a day-linked surface is made of, which names the thing to click in
* the caption: a point on a line, a bar, or a cell of a calendar.
*/
export type ChartDayMark = "point" | "bar" | "cell";
const HINT_KEYS: Record<ChartDayMark, { fine: string; coarse: string }> = {
point: { fine: "day.chartHint", coarse: "day.chartHintTouch" },
bar: { fine: "day.chartHintBar", coarse: "day.chartHintTouchBar" },
cell: { fine: "day.chartHintCell", coarse: "day.chartHintTouchCell" },
};
/**
* The one line under a day-linked chart: what the dots mean (when the chart
* has the row of dots), and the click.
*/
export function ChartDayCaption({
coarse,
mark = "point",
rug = true,
}: {
coarse: boolean;
mark?: ChartDayMark;
rug?: boolean;
}) {
const { t } = useTranslations();
const hint = HINT_KEYS[mark];
return (
<p
data-slot="chart-day-caption"
className="text-muted-foreground mt-1 flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs"
>
{rug ? <span>{t("day.rugLegend")}</span> : null}
<span>{coarse ? t(hint.coarse) : t(hint.fine)}</span>
</p>
);
}
+12
View File
@@ -717,6 +717,18 @@ function DaySheet({
event.preventDefault();
returnFocus();
}}
onEscapeKeyDown={(event) => {
// A dialog opened from inside the day (the date's calendar) is
// portaled out of the panel. For a moment after it opens, until
// Radix has re-rendered both layers, the panel still holds the
// Escape listener: an Escape aimed at the calendar then closed the
// whole day. Escape from inside another dialog is never the day's.
const owner =
event.target instanceof Element
? event.target.closest('[role="dialog"]')
: null;
if (owner && owner !== panelRef.current) event.preventDefault();
}}
className={cn(
// Quicker than the primitive's 500 ms, which read as lag on a
// layer opened from a tap; no slide at all under reduced motion.
@@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => {
);
});
});
/**
* "Open in new tab" hands the browser the types it renders itself, so a long
* report gets the browser's zoom and search. It must open the object URL the
* sheet already holds (a bare navigation to the route carries no record-session
* assertion and is refused once the session has been inside a shared record),
* and it must never appear for a type outside the inline serving class.
*/
describe("the detail sheet's open-in-new-tab control", () => {
const control = SHEET.slice(
SHEET.indexOf("function DocumentOpenInTabButton("),
SHEET.indexOf("/** Inline preview for Class A documents"),
);
it("exists", () => {
expect(control.length).toBeGreaterThan(0);
expect(SHEET).toMatch(/<DocumentOpenInTabButton\s/);
});
it("renders only for the inline serving class", () => {
expect(SHEET).toMatch(
/doc\.servingClass === "inline" \? \(\s*\n\s*<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}/,
);
expect([...SHEET.matchAll(/<DocumentOpenInTabButton\s/g)]).toHaveLength(1);
});
it("opens the fetched object URL, never the route, without an opener", () => {
expect(control).toContain('window.open(readyUrl, "_blank", "noopener")');
expect(control).not.toContain("/api/documents");
});
it("is disabled until the bytes are in hand, and when they failed", () => {
expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/);
expect(SHEET).toMatch(
/<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}\s+unavailable=\{inlineFailed\}/,
);
});
});
@@ -23,6 +23,7 @@ import {
ArrowRight,
Check,
Download,
ExternalLink,
FlaskConical,
Pencil,
Plus,
@@ -199,6 +200,43 @@ function DocumentDownloadButton({
);
}
/**
* Open the stored original in a new browser tab, for the types the browser
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
* long report gets the browser's own zoom and search.
*
* It opens the object URL the sheet already holds rather than the route: a
* plain navigation carries no record-session assertion, so the fence would
* refuse it once the session has been inside a shared record. The bytes are
* already in hand, so the tab opens inside the click and no popup blocker
* steps in. A type outside the inline class never gets the control.
*/
function DocumentOpenInTabButton({
readyUrl,
unavailable,
}: {
readyUrl: string | null;
unavailable: boolean;
}) {
const { t } = useTranslations();
return (
<Button
variant="outline"
data-slot="document-open-in-tab"
disabled={readyUrl === null || unavailable}
aria-label={t("documents.detail.openInNewTab")}
onClick={() => {
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
}}
>
<ExternalLink className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.openInNewTab")}
</span>
</Button>
);
}
/** Inline preview for Class A documents; skeleton until the blob paints. */
function InlinePreview({
src,
@@ -759,8 +797,8 @@ export function DocumentDetailSheet({
doc ? (
// Delete sits bottom-LEFT, quieted to a text button: the
// bottom-right slot reads as the primary/confirm action, and a
// destructive control there is a footgun. Share + Download — the
// safe, expected actions — keep the trailing edge. On phones their
// destructive control there is a footgun. Share, open in a new tab and
// Download, the safe, expected actions, keep the trailing edge. On phones their
// labels collapse to icon-only (aria-labelled) so three controls
// never overflow the sheet at 360 px; the labels return at `sm+`.
<div className="flex w-full items-center justify-between gap-2">
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({
</span>
</Button>
)}
{doc.servingClass === "inline" ? (
<DocumentOpenInTabButton
readyUrl={inlineSrc}
unavailable={inlineFailed}
/>
) : null}
<DocumentDownloadButton
documentId={doc.id}
filename={doc.filename}
@@ -27,10 +27,18 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
/** One visit by id, for a link to a visit older than the list reads. */
function fetchVisit(id: string): Promise<Encounter | null> {
return apiGet<Encounter>(`/api/encounters/${encodeURIComponent(id)}`).catch(
() => null,
);
}
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
@@ -60,6 +68,9 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
records: list.data ? [...upcoming, ...past] : undefined,
anchorAttribute: "data-encounter-id",
open: canManageVisits ? openSheet : undefined,
// The list reads the newest hundred of each arm; a search hit can name an
// older visit, which is read on its own.
resolve: fetchVisit,
});
const empty = upcoming.length === 0 && past.length === 0;
// The last visit that took place: the start of the visit preparation.
@@ -699,7 +699,7 @@ describe("<MessageThread>", () => {
/>,
);
expect(html).toContain(
"Daily limit reached. The budget refreshes at midnight UTC.",
"Daily limit reached. The budget refreshes at midnight in your time zone.",
);
// v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights
// provider"). Both old and new strings should be absent here when
@@ -560,7 +560,7 @@ function ChatBubbleImpl({
// v1.4.25 W5 — map server-emitted error codes to specific Coach
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
// user understands daily-cap (resets at UTC midnight) vs. transient
// user understands daily-cap (resets at their local midnight) vs. transient
// provider load (retry in ~5 min). Codes that have no dedicated
// translation fall back to the generic provider-unavailable copy.
const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null;
@@ -606,7 +606,6 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
: 0,
}))}
axis="band"
mark="bar"
// The plot's margin (8) + the y axis (60) on the left, the
// margin (16) on the right.
insetLeft={8 + 60}
@@ -18,6 +18,8 @@ interface MedicationStateBadgesProps {
lastCourseEndedOn?: string | null;
/** v1.40 (#1024) — the running course's number, when there are several. */
courseNumber?: number | null;
/** v1.42.2 — doses are recorded automatically (`autoMarkTaken`). */
autoMarkTaken?: boolean;
}
/**
@@ -34,6 +36,7 @@ export function MedicationStateBadges({
courseEnded = false,
lastCourseEndedOn = null,
courseNumber = null,
autoMarkTaken = false,
}: MedicationStateBadgesProps) {
const { t, locale } = useTranslations();
const dateFormatPref = useDateFormatPreference();
@@ -75,11 +78,24 @@ export function MedicationStateBadges({
{t("medications.course.nth", { n: courseNumber })}
</Badge>
)}
{!notificationsEnabled && !recordOnly && !courseEnded && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
{/* v1.42.2 — a quiet meta line, not a status badge: the doses are
recorded for the person, and no reminder is sent for them. */}
{autoMarkTaken && active && !recordOnly && !courseEnded && (
<span
className="text-muted-foreground text-xs"
data-slot="medication-auto-mark-taken-hint"
>
{t("medications.autoMarkTaken.cardHint")}
</span>
)}
{!notificationsEnabled &&
!autoMarkTaken &&
!recordOnly &&
!courseEnded && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
)}
{!active && (
<Badge variant="secondary" className="text-xs">
{pausedAt
@@ -93,6 +93,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { formatDose } from "@/lib/medications/format-dose";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-taken-section";
import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
@@ -174,6 +175,8 @@ export interface MedicationDetailSnapshot {
* `recordedSchedules`; the page shows (and edits) the stored rows.
*/
trackIntake?: boolean;
/** v1.42.2 — doses recorded automatically. */
autoMarkTaken?: boolean;
/** v1.39.4 (#1040) — where today sits in the course (server-resolved). */
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
/** v1.40 (#1024) — the server-resolved courses. */
@@ -253,6 +256,7 @@ function snapshotToWizardPayload(
oneShot: med.oneShot ?? false,
asNeeded: med.asNeeded ?? false,
trackIntake: med.trackIntake !== false,
autoMarkTaken: med.autoMarkTaken === true,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
@@ -731,6 +735,15 @@ export function MedicationDetailTabs({
dataSlot="medication-zeitplan-reminder"
>
<div className="space-y-4">
{/* v1.42.2 — leads the reminder card: with it on, the doses
are recorded and no reminder is sent. */}
{medication.asNeeded !== true && (
<AutoMarkTakenBody
key={medication.autoMarkTaken ? "on" : "off"}
medicationId={id}
autoMarkTaken={medication.autoMarkTaken === true}
/>
)}
<NotificationsBody
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
@@ -141,6 +141,8 @@ export interface Glp1Medication {
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
/** v1.42.2 — doses recorded automatically. */
autoMarkTaken?: boolean;
/**
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
* when true (active, tracked, and the course includes today).
@@ -464,6 +466,7 @@ export function Glp1MedicationCard({
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
autoMarkTaken={medication.autoMarkTaken === true}
courseEnded={medication.courseStatus === "ENDED"}
lastCourseEndedOn={medication.previousCourseEndedOn ?? null}
courseNumber={currentCourseNumber(medication)}
@@ -92,6 +92,8 @@ export interface IntakeEvent {
takenAt: string | null;
skipped: boolean;
source: IntakeSourceValue;
/** v1.42.2 — recorded by the server (`autoMarkTaken`), not the person. */
autoRecorded?: boolean;
createdAt: string;
}
@@ -430,10 +432,12 @@ export function IntakeHistoryListV2({
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
{event.autoRecorded
? t("medications.intakeRecordedAutomatically")
: t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
</Badge>
</TableCell>
{showRowActions && (
@@ -535,10 +539,13 @@ export function IntakeHistoryListV2({
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
{event.autoRecorded
? t("medications.intakeRecordedAutomatically")
: t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ??
event.source,
})}
</Badge>
</div>
</div>
@@ -126,6 +126,8 @@ interface Medication {
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
/** v1.42.2 — doses recorded automatically. */
autoMarkTaken?: boolean;
/**
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
* when true (active, tracked, and the course includes today).
@@ -407,6 +409,7 @@ export function MedicationCard({
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
autoMarkTaken={medication.autoMarkTaken === true}
courseEnded={medication.courseStatus === "ENDED"}
lastCourseEndedOn={medication.previousCourseEndedOn ?? null}
courseNumber={currentCourseNumber(medication)}
@@ -0,0 +1,94 @@
"use client";
/**
* v1.42.2 — the per-medication "record doses automatically" switch.
*
* The same switch row as the intake-tracking and reminder rows beside it:
* the whole row is the hit target, the helper line says what the option
* does. Flipping writes `PUT /api/medications/[id]` with `{ autoMarkTaken }`;
* the server stamps the moment it was switched on, and only slots from then
* on are ever recorded for the person.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { apiPut } from "@/lib/api/api-fetch";
const SWITCH_ID = "medication-detail-auto-mark-taken-switch";
const ROW_TITLE_ID = "medication-detail-auto-mark-taken-label";
const HELPER_ID = "medication-detail-auto-mark-taken-helper";
export function AutoMarkTakenBody({
medicationId,
autoMarkTaken,
}: {
medicationId: string;
autoMarkTaken: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [localOn, setLocalOn] = useState(autoMarkTaken);
const [submitting, setSubmitting] = useState(false);
async function flip(next: boolean) {
if (submitting) return;
setSubmitting(true);
const previous = localOn;
setLocalOn(next);
try {
await apiPut(`/api/medications/${medicationId}`, {
autoMarkTaken: next,
});
await invalidateKeys(queryClient, medicationDependentKeys);
await refetchInactiveDailyReads(queryClient);
toast.success(
next
? t("medications.autoMarkTaken.enabledToast")
: t("medications.autoMarkTaken.disabledToast"),
);
} catch {
setLocalOn(previous);
toast.error(t("medications.autoMarkTaken.toggleFailed"));
} finally {
setSubmitting(false);
}
}
return (
<label
htmlFor={SWITCH_ID}
className="flex items-center justify-between gap-3"
data-slot="medication-auto-mark-taken-row"
>
<span className="space-y-1">
<span
id={ROW_TITLE_ID}
className="text-foreground block text-sm font-medium"
>
{t("medications.autoMarkTaken.label")}
</span>
<span id={HELPER_ID} className="text-muted-foreground block text-xs">
{t("medications.autoMarkTaken.helper")}
</span>
</span>
<Switch
id={SWITCH_ID}
checked={localOn}
disabled={submitting}
onCheckedChange={(checked) => void flip(checked)}
aria-labelledby={ROW_TITLE_ID}
aria-describedby={HELPER_ID}
data-slot="medication-auto-mark-taken-switch"
/>
</label>
);
}
@@ -197,6 +197,33 @@ export function Step8Summary({
</div>
)}
{/* v1.42.2 — auto-mark-taken. Only meaningful while doses are due:
hidden for as-needed and record-only medications. */}
{payload.mode !== "asNeeded" && payload.trackIntake && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label
htmlFor="wizard-auto-mark-taken"
className="text-sm font-medium"
>
{t("medications.autoMarkTaken.label")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.autoMarkTaken.helper")}
</p>
</div>
<Switch
id="wizard-auto-mark-taken"
checked={payload.autoMarkTaken}
onCheckedChange={(checked) =>
applyPartial({ autoMarkTaken: checked })
}
data-slot="wizard-auto-mark-taken-toggle"
aria-label={t("medications.autoMarkTaken.label")}
/>
</div>
)}
{/* v1.16.11 — an as-needed medication never reminds (no slots),
so the reminders toggle is hidden alongside the schedules. */}
{payload.mode !== "asNeeded" && payload.trackIntake && (
@@ -346,6 +346,11 @@ export interface WizardPayload {
* schedule as a record: nothing due, no reminders, no adherence.
*/
trackIntake: boolean;
/**
* v1.42.2 — record each scheduled dose as taken automatically once its
* time has passed. No reminder is sent while it is on.
*/
autoMarkTaken: boolean;
/** Every parallel schedule under construction. Always >= 1 entry. */
schedules: ScheduleDraft[];
/** Which schedule Steps 5-7 currently edit. */
@@ -395,6 +400,7 @@ export function emptyWizardPayload(): WizardPayload {
endsOn: null,
notificationsEnabled: true,
trackIntake: true,
autoMarkTaken: false,
schedules: [draft],
activeScheduleIndex: 0,
};
@@ -695,6 +701,12 @@ export interface CreateMedicationBody {
* always tracks intake.
*/
trackIntake: boolean;
/**
* v1.42.2 — auto-mark-taken, sent on every create and edit (hydrated from
* the stored value). The server stamps its floor only on an off→on flip,
* so re-sending the stored value never moves it. Off for as-needed.
*/
autoMarkTaken: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -923,6 +935,8 @@ export function buildCreateBody(
// medication kept as a record. Turning a medication into an as-needed
// one means doses will be logged, so it tracks intake again.
trackIntake: isAsNeeded ? true : committed.trackIntake,
// An as-needed medication has no slot to record.
autoMarkTaken: isAsNeeded ? false : committed.autoMarkTaken,
schedules: draftsToEmit.map((draft) =>
encodeScheduleDraft(draft, isOneShot),
),
@@ -1149,6 +1163,8 @@ export interface MedicationPayload {
asNeeded?: boolean;
/** v1.39.1 (#1033) — intake tracking; absent = tracked. */
trackIntake?: boolean;
/** v1.42.2 — auto-mark-taken; absent = off. */
autoMarkTaken?: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -1301,6 +1317,7 @@ export function hydrateWizardPayload(
endsOn: initial.endsOn,
notificationsEnabled: initial.notificationsEnabled ?? true,
trackIntake: initial.trackIntake !== false,
autoMarkTaken: initial.autoMarkTaken === true,
schedules: drafts,
activeScheduleIndex: 0,
};
+2
View File
@@ -1395,6 +1395,8 @@ function formatMoodSource(
return t("mood.sourceTelegram");
case "DAYLIO":
return t("mood.sourceDaylio");
case "NOTIFICATION":
return t("mood.sourceNotification");
default:
return source;
}
@@ -61,9 +61,42 @@ describe("oauthReasonKey", () => {
);
});
it("names Oura's issuer refusal with its own copy, and only for Oura", () => {
expect(oauthReasonKey("oura", "issuer")).toBe(
"settings.ouraOauthError.issuer",
);
// No other callback emits it, and no other subtree carries the copy.
expect(oauthReasonKey("polar", "issuer")).toBe(
"settings.polarOauthError.generic",
);
});
it("falls back to generic for an unknown tag", () => {
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
"settings.whoopOauthError.generic",
);
});
});
describe("the Oura callback's reasons", () => {
it("each has its own copy, none falls back to generic", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const route = readFileSync(
join(process.cwd(), "src/app/api/oura/callback/route.ts"),
"utf8",
);
const reasons = [...route.matchAll(/ERR\("([a-z_0-9]+)"\)/g)].map(
(m) => m[1],
);
expect(reasons).toContain("issuer");
const en = JSON.parse(
readFileSync(join(process.cwd(), "messages/en.json"), "utf8"),
) as { settings: { ouraOauthError: Record<string, string> } };
for (const reason of reasons) {
const key = oauthReasonKey("oura", reason);
expect(key, reason).toBe(`settings.ouraOauthError.${reason}`);
expect(en.settings.ouraOauthError[reason], reason).toBeTruthy();
}
});
});
@@ -127,6 +127,17 @@ const OAUTH_OUTCOME_REASONS = new Set([
"expired",
]);
/**
* Reason tags only one provider's callback emits, with copy under that
* provider's subtree alone. Oura's `issuer`: the authorization response named
* an issuer that is not Oura's (RFC 9207), so the callback refused the code.
*/
const PROVIDER_ONLY_REASONS: Partial<
Record<OAuthOutcomeProvider, ReadonlySet<string>>
> = {
oura: new Set(["issuer"]),
};
type OAuthOutcome =
| { provider: OAuthOutcomeProvider; kind: "connected" }
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
@@ -162,7 +173,8 @@ export function oauthReasonKey(
reason: string,
): string {
const base = OAUTH_ERROR_KEY_BASE[provider];
return OAUTH_OUTCOME_REASONS.has(reason)
return OAUTH_OUTCOME_REASONS.has(reason) ||
PROVIDER_ONLY_REASONS[provider]?.has(reason)
? `${base}.${reason}`
: `${base}.generic`;
}
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LockKeyhole } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
interface NotificationPrefsShape {
content?: { detail: "generic" | "detailed" };
updatedAt?: string;
}
/**
* Settings → Notifications: what a notification may show on a lock screen.
*
* Off (the default) keeps every notification to what it is about ("Your
* morning briefing"); on adds the health figures a notification has, today
* the morning score. Stored as `notificationPrefs.content.detail` through
* the same PATCH the iOS app uses, with the optimistic-concurrency token, so
* a stale web write cannot revert a sibling category.
*/
export function NotificationContentCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current);
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current);
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const { data: prefs } = useQuery({
queryKey: queryKeys.authNotificationPrefs(),
queryFn: async () =>
apiGet<NotificationPrefsShape>("/api/auth/me/notification-prefs"),
enabled: isAuthenticated,
});
const detailed = optimistic ?? prefs?.content?.detail === "detailed";
async function handleToggle(next: boolean) {
setOptimistic(next);
setSaving(true);
setMsg(null);
setMsgType(null);
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ content: { detail: next ? "detailed" : "generic" } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
});
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic(null);
if (res.ok) {
setMsg(
next
? t("notifications.content.detailedToast")
: t("notifications.content.genericToast"),
);
setMsgType("success");
} else if (res.status === 409) {
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.content.saveError"));
setMsgType("error");
}
scheduleClear();
setSaving(false);
}
return (
<SettingsCard
as="section"
aria-labelledby="settings-notification-content-title"
>
<SettingsCardHeader
icon={LockKeyhole}
title={t("notifications.content.title")}
titleId="settings-notification-content-title"
description={t("notifications.content.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={detailed}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.content.toggleAria")}
/>
<span className="text-muted-foreground text-xs">
{detailed
? t("notifications.content.statusDetailed")
: t("notifications.content.statusGeneric")}
</span>
</label>
}
/>
<p className="text-sm">{t("notifications.content.detail")}</p>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-sm"
: "text-muted-foreground text-sm"
}
>
{msg}
</p>
)}
</SettingsCard>
);
}
@@ -1,13 +1,14 @@
"use client";
import Link from "next/link";
import { Bell } from "lucide-react";
import { Bell, Smartphone } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationContentCard } from "@/components/settings/notification-content-card";
import { Button } from "@/components/ui/button";
/**
@@ -72,6 +73,18 @@ export function NotificationsSection() {
) : null}
</section>
) : null}
{/* Group 2 — How notifications look on the phone. Not module-gated:
it applies to every notification the account receives. */}
<section className="space-y-4">
<SectionHeading
icon={Smartphone}
title={t("settings.sections.notifications.displayHeading")}
/>
<div id="notification-content" className="scroll-mt-28">
<NotificationContentCard isAuthenticated={isAuthenticated} />
</div>
</section>
{/* Cross-links to the other two notification surfaces. At the END of
the page: a screen that opens with two ways off it never says what
it is for. */}
@@ -46,6 +46,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TagChip } from "@/components/ui/tag-chip";
import { QuickEntrySheets } from "@/components/dashboard/quick-entry-sheets";
import { useAuth } from "@/hooks/use-auth";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
@@ -369,6 +370,15 @@ export function SymptomsSection() {
const hidden = all.filter((d) => !d.isActive);
const days = groupEventsByDay(events.data?.events ?? [], timezone);
// `?symptom=<id>` — a search hit names the symptom. Scroll to it, and open
// it for someone who may edit it.
useDeepLinkedRecord({
param: "symptom",
records: definitions.data ? all : undefined,
anchorAttribute: "data-symptom-id",
open: canManage ? setEditingDefinition : undefined,
});
if (definitions.isLoading) return null;
if (definitions.isError || events.isError) {
return (
@@ -413,6 +423,7 @@ export function SymptomsSection() {
return (
<li
key={definition.id}
data-symptom-id={definition.id}
className="flex items-center justify-between gap-3 py-2.5"
>
<div className="flex min-w-0 items-center gap-2">
@@ -449,6 +460,7 @@ export function SymptomsSection() {
{hidden.map((definition) => (
<li
key={definition.id}
data-symptom-id={definition.id}
className="flex items-center justify-between gap-3 py-1.5"
>
<span className="text-muted-foreground truncate text-sm">
@@ -0,0 +1,179 @@
/**
* v1.42.2 — the timeline never opens on a dead end.
*
* - Opened without a zoom, when the default three months hold nothing but
* the record has older entries, the page shows everything (and leaves
* the URL alone).
* - A window named in the URL stays that window; when it is empty and the
* record has entries before it, it offers "Show everything".
* - An empty record keeps the plain empty state.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { TimelineResponse, TimelineZoom } from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
import { TODAY, fullTimeline, readiness } from "./timeline-fixture";
let search = new URLSearchParams();
let dataFrom: string | null = "2019-01-01";
/** A young record: everything sits inside the default three months. */
let young = false;
const asked: TimelineZoom[] = [];
/** Everything for `all`; any other window holds nothing. */
function answer(zoom: TimelineZoom, from: string | null, to: string | null) {
const full = fullTimeline();
if (young) {
return {
...full,
zoom,
range: { from: from ?? "2026-08-01", to: to ?? TODAY, dataFrom },
};
}
if (zoom === "all" && dataFrom !== null) return { ...full, zoom };
const empty: TimelineResponse = {
...full,
zoom,
range: { from: from ?? "2026-08-01", to: to ?? TODAY, dataFrom },
lanes: full.lanes.map((lane) => ({ ...lane, items: [] })),
standing: [],
series: [],
};
return empty;
}
vi.mock("next/navigation", () => ({
usePathname: () => "/timeline",
useSearchParams: () => search,
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", timezone: "Europe/Berlin", modules: {} },
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: false }),
}));
vi.mock("../use-timeline", () => ({
useTimeline: (
zoom: TimelineZoom,
from: string | null,
to: string | null,
_values: readonly string[],
enabled = true,
) => {
if (!enabled) {
return {
data: undefined,
isPending: true,
isError: false,
isPlaceholderData: false,
refetch: vi.fn(),
};
}
asked.push(zoom);
return {
data: answer(zoom, from, to),
isPending: false,
isError: false,
isPlaceholderData: false,
refetch: vi.fn(),
};
},
useTimelineReadiness: () => ({
data: readiness(),
isPending: false,
isError: false,
}),
useLifeEvents: () => ({ data: { events: [] } }),
useLifeEventMutations: () => ({
create: { isPending: false },
update: { isPending: false },
remove: { isPending: false },
}),
}));
vi.mock("../timeline-dates", async (importOriginal) => {
const real = await importOriginal<typeof import("../timeline-dates")>();
return { ...real, todayKeyIn: () => TODAY };
});
let TimelineView: typeof import("../timeline-view").TimelineView;
beforeAll(async () => {
({ TimelineView } = await import("../timeline-view"));
}, 60_000);
beforeEach(() => {
search = new URLSearchParams();
dataFrom = "2019-01-01";
young = false;
asked.length = 0;
});
function render(): string {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<TimelineView />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("the timeline's default window", () => {
it("opens on everything when three months hold nothing but the record is older", () => {
const html = render();
expect(asked).toContain("quarter");
expect(asked).toContain("all");
expect(html).toMatch(/data-slot="timeline-desktop"[^>]*data-zoom="all"/);
expect(html).not.toContain('data-slot="timeline-window-empty"');
});
it("an unreadable zoom opens the same way as none", () => {
search = new URLSearchParams({ zoom: "decade" });
const html = render();
// It asks for the default three months first, as without a zoom.
expect(asked[0]).toBe("quarter");
expect(html).toMatch(/data-slot="timeline-desktop"[^>]*data-zoom="all"/);
});
it("a window named in the URL stays, and offers everything", () => {
search = new URLSearchParams({ zoom: "quarter" });
const html = render();
expect(asked).not.toContain("all");
expect(html).toContain('data-slot="timeline-window-empty"');
expect(html).toContain('data-slot="timeline-show-everything"');
expect(html).not.toContain('data-slot="timeline-desktop"');
});
it("a young record opens on three months clamped to its first entry", () => {
young = true;
dataFrom = "2026-09-01";
const html = render();
expect(asked).not.toContain("all");
expect(html).toMatch(
/data-slot="timeline-desktop"[^>]*data-zoom="quarter"/,
);
expect(html).toMatch(/data-window-from="2026-09-01"/);
expect(html).toMatch(/data-window-to="2026-10-31"/);
});
it("an older record with entries in the three months opens on them", () => {
young = true;
const html = render();
expect(asked).not.toContain("all");
expect(html).toMatch(/data-window-from="2026-08-01"/);
});
it("an empty record keeps the plain empty state", () => {
dataFrom = null;
const html = render();
expect(asked).not.toContain("all");
expect(html).not.toContain('data-slot="timeline-show-everything"');
expect(html).not.toContain('data-slot="timeline-desktop"');
});
});
@@ -3,7 +3,13 @@ import { describe, expect, it } from "vitest";
import { TIMELINE_RANGE_MAX_DAYS } from "@/lib/day/contract";
import { dayKey, dayNumber } from "../timeline-dates";
import { clampRange, parseTimelineUrl, timelineSearch } from "../timeline-url";
import {
clampRange,
hasDataBeforeWindow,
parseTimelineUrl,
resolveDefaultWindow,
timelineSearch,
} from "../timeline-url";
const TODAY = "2026-10-07";
const parse = (search: string) =>
@@ -26,7 +32,7 @@ describe("timeline URL", () => {
});
});
it("falls back to all, without a word, on anything it cannot read", () => {
it("reads anything it cannot read as no zoom, the same default as none", () => {
for (const search of [
"zoom=decade",
"zoom=range",
@@ -36,10 +42,45 @@ describe("timeline URL", () => {
"zoom=range&from=2027-01-01&to=2027-02-01",
"zoom=range&from=yesterday&to=today",
]) {
expect(parse(search), search).toEqual({ zoom: "all", range: null });
expect(parse(search), search).toEqual(parse(""));
}
});
it("knows when a window misses the record's older entries", () => {
const quarter = { from: "2026-08-01" };
expect(hasDataBeforeWindow("2025-02-11", quarter)).toBe(true);
expect(hasDataBeforeWindow("2026-08-01", quarter)).toBe(false);
expect(hasDataBeforeWindow("2026-09-15", quarter)).toBe(false);
expect(hasDataBeforeWindow(null, quarter)).toBe(false);
expect(hasDataBeforeWindow("2025-02-11", null)).toBe(false);
});
it("adapts the default window to the record", () => {
// The default three months up to 2026-10-07.
const window = { from: "2026-08-01", to: "2026-10-31" };
const decide = (dataFrom: string | null, windowHasData: boolean) =>
resolveDefaultWindow({ dataFrom, window, windowHasData, today: TODAY });
// 1. A young record starts at its first entry...
expect(decide("2026-09-01", true)).toEqual({
zoom: "quarter",
from: "2026-09-01",
});
// ...but spans at least four weeks up to today.
expect(decide("2026-10-05", true)).toEqual({
zoom: "quarter",
from: "2026-09-10",
});
// A first entry on the window's first day is the plain three months.
expect(decide("2026-08-01", true)).toEqual({ zoom: "quarter", from: null });
// 2. An older record with entries in the three months: three months.
expect(decide("2019-01-01", true)).toEqual({ zoom: "quarter", from: null });
// 3. An older record with none there: everything.
expect(decide("2019-01-01", false)).toEqual({ zoom: "all" });
// 4. No data at all: the plain empty state.
expect(decide(null, false)).toBeNull();
});
it("pulls a range's end back to today and its span under the cap", () => {
expect(parse("zoom=range&from=2026-09-01&to=2027-01-01").range).toEqual({
from: "2026-09-01",
@@ -230,6 +230,8 @@ export function TimelineChart({
onKeyDown={handleKey}
data-slot="timeline-chart"
data-width={width}
data-window-from={window.from}
data-window-to={window.to}
className="focus-visible:ring-ring/50 relative rounded-lg outline-none focus-visible:ring-2"
>
<p id="timeline-chart-help" className="sr-only">
+60 -4
View File
@@ -2,8 +2,9 @@
* The timeline's zoom and chosen range in the URL (v1.42):
* `?zoom=year`, `?zoom=range&from=2026-03-01&to=2026-04-15`. Back and a
* bookmark both land where the person was. Anything that does not read as
* a zoom, or a range without two valid ends, falls back to `all` without a
* word: a stale bookmark should open the timeline, not an error.
* a zoom, or a range without two valid ends, reads as no zoom at all and
* opens on the same default without a word: a stale bookmark should open
* the timeline, not an error.
*/
import {
TIMELINE_RANGE_MAX_DAYS,
@@ -57,7 +58,6 @@ export function parseTimelineUrl(
const zoom = (TIMELINE_ZOOMS as readonly string[]).includes(raw ?? "")
? (raw as TimelineZoom)
: null;
if (raw !== null && zoom === null) return { zoom: "all", range: null };
if (zoom !== "range") return { zoom, range: null };
const from = params.get(FROM_PARAM);
const to = params.get(TO_PARAM);
@@ -69,11 +69,67 @@ export function parseTimelineUrl(
from > to ||
from > today
) {
return { zoom: "all", range: null };
return { zoom: null, range: null };
}
return { zoom, range: clampRange({ from, to }, today, null) };
}
/** The zoom the page opens on when the URL names none. */
export const DEFAULT_ZOOM: TimelineZoom = "quarter";
/** The shortest default window over a young record, in days to today. */
export const YOUNG_RECORD_MIN_DAYS = 28;
/**
* What the page opens on when the URL names no zoom, decided from the
* default three months' answer (`window`, whether it holds anything, and
* the record's first entry):
*
* - a record that starts inside the three months opens on three months
* clamped to its first entry, at least four weeks up to today, so a
* few days of data neither hide in a sliver nor stretch thin
* (`from` set);
* - an older record with entries in the three months opens on them
* (`from` null);
* - an older record with none there opens on everything;
* - an empty record: null, the plain empty state.
*/
export type DefaultWindow =
{ zoom: "quarter"; from: string | null } | { zoom: "all" } | null;
export function resolveDefaultWindow(input: {
dataFrom: string | null;
window: { from: string; to: string };
windowHasData: boolean;
today: string;
}): DefaultWindow {
const { dataFrom, window, today } = input;
if (dataFrom === null) return null;
if (dataFrom >= window.from) {
const minimum = dayKey(dayNumber(today) - (YOUNG_RECORD_MIN_DAYS - 1));
const start = dataFrom < minimum ? dataFrom : minimum;
return {
zoom: "quarter",
from: start > window.from ? start : null,
};
}
return input.windowHasData
? { zoom: "quarter", from: null }
: { zoom: "all" };
}
/**
* True when a window shows nothing while the record has entries from before
* it: the page then offers everything instead of a dead end. `dataFrom` is
* the earliest date anything in the record carries.
*/
export function hasDataBeforeWindow(
dataFrom: string | null,
window: { from: string } | null,
): boolean {
return dataFrom !== null && window !== null && dataFrom < window.from;
}
/**
* The search string for a zoom (and, for `range`, its ends), keeping every
* other parameter (`day`, `add`) as it is.
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff. Show more