mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
110 files changed
+11501
-1241
No files matched your search
@@ -375,11 +375,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# per-user BYO keys keep the pinned public-host posture either way.
|
||||
# ADMIN_AI_BASE_URL_ALLOWLIST=""
|
||||
|
||||
# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the
|
||||
# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright
|
||||
# (comma-separated, first match wins). Reach for these when the provider
|
||||
# rotates a slug and requests start failing with 503 -- without them a
|
||||
# rotation needs a new release to fix.
|
||||
# Codex (ChatGPT sign-in) model slugs. Left unset, each sign-in picks from
|
||||
# the models its account is offered (read from the provider the way the Codex
|
||||
# CLI reads it), with a built-in order as the fallback. CODEX_MODEL pins one
|
||||
# slug ahead of that; CODEX_MODEL_FALLBACK_CHAIN replaces the order outright
|
||||
# (comma-separated, first match wins) and the account's list is then not read.
|
||||
# CODEX_MODEL=""
|
||||
# CODEX_MODEL_FALLBACK_CHAIN=""
|
||||
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [1.46.3] — 2026-10-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex picks from what the account is offered.** OpenAI retires Codex
|
||||
models for ChatGPT sign-in in steps, and every retirement left Codex
|
||||
refusing every request until a release changed the built-in list. Each
|
||||
sign-in now reads its account's model list, the way the Codex CLI does,
|
||||
and chooses from it in HealthLog's order of preference. Whether a model
|
||||
reads images comes from the same list. If the list can't be read, the
|
||||
built-in order applies, and `CODEX_MODEL` and
|
||||
`CODEX_MODEL_FALLBACK_CHAIN` keep their meaning. The working model is
|
||||
remembered per sign-in instead of in one slot for the whole server, so
|
||||
two accounts on different plans, or the shared central connection, no
|
||||
longer overwrite each other, and a sign-in reconnected to another
|
||||
account starts fresh. (#1287)
|
||||
- **A gateway that refuses the reasoning field is asked without it for a
|
||||
day.** The lesson held for an hour, and every expiry cost one refused
|
||||
request, a few a day on a gateway that runs the briefings unattended.
|
||||
Lessons read from a single reply keep the hour. (#1279)
|
||||
|
||||
### Added
|
||||
|
||||
- **Lab values on the timeline.** The values menu lists the lab markers
|
||||
the record has, and a chosen marker draws its readings as their own
|
||||
points, never averaged, with the reference range behind them. A reading
|
||||
outside the range is drawn as a triangle and named in words. A tap shows
|
||||
the day, the value, the range and the report it came from. Readings in a
|
||||
unit that cannot be converted are left out and the timeline says how
|
||||
many. Up to 24 markers at once; the choice is remembered like the other
|
||||
value lines. (#1299)
|
||||
- **Restoring the backup of an erased account.** An upload whose account
|
||||
no longer exists used to be refused with nowhere to go. Admin, Backups
|
||||
now offers to restore it into a new account created for it, or into an
|
||||
existing account that holds no records beyond what sign-up and setup
|
||||
leave, confirmed by typing its username. Admin accounts are not offered.
|
||||
Nothing names the erased account afterwards. A new account has no
|
||||
password until an admin sets one with Reset password. Restoring the same
|
||||
file into a second account is refused with an explanation instead of a
|
||||
generic failure. The runbook in `docs/ops/backup-restore.md` has the
|
||||
details.
|
||||
|
||||
### Thanks
|
||||
|
||||
@haritowa for taking the Codex retirements apart in #1287 and proposing
|
||||
the live list. @mills1975 for the timeline idea in #1299. @Tio61 for three
|
||||
days of request counts that showed what the hour still cost (#1279).
|
||||
|
||||
## [1.46.2] — 2026-10-29
|
||||
|
||||
### Fixed
|
||||
|
||||
+5
-5
@@ -347,11 +347,11 @@ x-healthlog-env: &healthlog_env
|
||||
# you run a gateway this instance should trust; it widens only the admin
|
||||
# setting, never the per-user BYO-key posture.
|
||||
ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}"
|
||||
# Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug
|
||||
# ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma-
|
||||
# separated) to replace the chain outright. Reach for these when the
|
||||
# provider rotates a slug and every request starts coming back 503 —
|
||||
# without them a rotation needs a new release to fix.
|
||||
# Codex (ChatGPT sign-in) model slugs. Unset, each sign-in picks from the
|
||||
# models its account is offered, with a built-in order as the fallback.
|
||||
# Set CODEX_MODEL to pin one slug ahead of that, or
|
||||
# CODEX_MODEL_FALLBACK_CHAIN (comma-separated) to replace the order
|
||||
# outright; the account's list is then not read.
|
||||
CODEX_MODEL: "${CODEX_MODEL:-}"
|
||||
CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}"
|
||||
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot.
|
||||
|
||||
+278
-42
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.46.2
|
||||
version: 1.46.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -25090,10 +25090,52 @@ paths:
|
||||
same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the
|
||||
backup schema, was written by a newer schema version than this server reads, needs an encryption key this server
|
||||
does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account
|
||||
that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens
|
||||
cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
|
||||
/api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
|
||||
the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
|
||||
that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists
|
||||
here (erased, say) can be sent again with a target in the query: `into=existing&userId=<id>&confirm=<username>`
|
||||
for an account that holds no records yet, or `into=new&username=<name>` for a new account created for the copy,
|
||||
with no password and no email (an admin sets its first password with the existing password reset). The file's
|
||||
owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like
|
||||
any other; the erased account's id is not stored. A target is refused before the file is read when the account
|
||||
does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does
|
||||
not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three
|
||||
uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A
|
||||
browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last
|
||||
five minutes (with a second factor, on an account that has one). Without it the answer is 401
|
||||
`auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
|
||||
parameters:
|
||||
- in: query
|
||||
name: into
|
||||
schema:
|
||||
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
|
||||
which has to exist."
|
||||
type: string
|
||||
enum:
|
||||
- existing
|
||||
- new
|
||||
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
|
||||
which has to exist."
|
||||
- in: query
|
||||
name: userId
|
||||
schema:
|
||||
description: "With `into=existing`: the account's id."
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: "With `into=existing`: the account's id."
|
||||
- in: query
|
||||
name: confirm
|
||||
schema:
|
||||
description: "With `into=existing`: the account's username, typed by the operator."
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: "With `into=existing`: the account's username, typed by the operator."
|
||||
- in: query
|
||||
name: username
|
||||
schema:
|
||||
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 30
|
||||
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -25139,6 +25181,22 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403": *a25
|
||||
"404":
|
||||
description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: "The chosen target cannot take the copy, and nothing was stored: a managed profile
|
||||
(`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an admin
|
||||
account (`backup.target.admin_account`), an account that already holds records
|
||||
(`backup.target.holds_records`, `meta.models` naming them), a username already taken
|
||||
(`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this
|
||||
server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` =
|
||||
@@ -25150,8 +25208,9 @@ paths:
|
||||
"422":
|
||||
description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as
|
||||
gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not
|
||||
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist
|
||||
here."
|
||||
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here
|
||||
(`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose
|
||||
`confirm` is not the account's username (`backup.target.unconfirmed`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -26131,11 +26190,19 @@ paths:
|
||||
- in: query
|
||||
name: values
|
||||
schema:
|
||||
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts
|
||||
once, so the list is bounded by the names that exist; an unknown name answers 422.
|
||||
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
|
||||
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
|
||||
names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab
|
||||
markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones
|
||||
left out without an error. A lab marker that is not in the record (deleted, or another record's) is
|
||||
likewise left out of `labSeries`, so a stored choice never breaks the page."
|
||||
type: string
|
||||
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts
|
||||
once, so the list is bounded by the names that exist; an unknown name answers 422.
|
||||
description: "Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
|
||||
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
|
||||
names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers
|
||||
are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out
|
||||
without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out
|
||||
of `labSeries`, so a stored choice never breaks the page."
|
||||
responses:
|
||||
"200":
|
||||
description: The timeline for the requested zoom and window.
|
||||
@@ -38648,34 +38715,38 @@ components:
|
||||
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
|
||||
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
|
||||
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
|
||||
backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active,
|
||||
backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
|
||||
body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
|
||||
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
|
||||
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
|
||||
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
|
||||
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired,
|
||||
consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich —
|
||||
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
|
||||
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
|
||||
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
|
||||
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
|
||||
documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound,
|
||||
documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic,
|
||||
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
|
||||
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
|
||||
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
|
||||
backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere,
|
||||
backup.section.missing, backup.target.admin_account, backup.target.holds_records,
|
||||
backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion,
|
||||
backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers —
|
||||
biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded,
|
||||
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
|
||||
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
|
||||
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
|
||||
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
|
||||
consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid,
|
||||
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
|
||||
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
|
||||
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
|
||||
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed;
|
||||
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
|
||||
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
|
||||
demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
|
||||
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted,
|
||||
documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit,
|
||||
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
|
||||
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
|
||||
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
|
||||
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
|
||||
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
|
||||
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
|
||||
documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
@@ -90542,9 +90613,11 @@ components:
|
||||
type: string
|
||||
description: "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`,
|
||||
`incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`,
|
||||
`document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`,
|
||||
`transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`.
|
||||
Every code but `failed_after_commit` means the account was not changed."
|
||||
`document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`,
|
||||
`backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account
|
||||
already holds, because the same file was restored there before), `time_budget`, `transaction_failed`,
|
||||
`interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but
|
||||
`failed_after_commit` means the account was not changed."
|
||||
message:
|
||||
type: string
|
||||
sections:
|
||||
@@ -92030,6 +92103,18 @@ components:
|
||||
description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live
|
||||
readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has
|
||||
entries. Each may be named in `values`."
|
||||
labSeries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimelineLabSeries"
|
||||
description: The lab markers named in `values`, in the order named, each with its single readings in the window. A
|
||||
marker named but not found in the record is left out.
|
||||
availableLabs:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimelineLabMarker"
|
||||
description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs.
|
||||
Each may be named in `values` by its `key`.
|
||||
notable:
|
||||
type: array
|
||||
items:
|
||||
@@ -92057,6 +92142,8 @@ components:
|
||||
- bucket
|
||||
- series
|
||||
- availableSeries
|
||||
- labSeries
|
||||
- availableLabs
|
||||
- notable
|
||||
additionalProperties: false
|
||||
TimelineItem:
|
||||
@@ -92209,6 +92296,155 @@ components:
|
||||
- unit
|
||||
- points
|
||||
additionalProperties: false
|
||||
TimelineLabSeries:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: "`lab:<biomarkerId>`, as named in `values`."
|
||||
biomarkerId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description: The marker's name, as the labs list shows it.
|
||||
panel:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
unit:
|
||||
type: string
|
||||
description: The marker's own unit; every point is in it.
|
||||
band:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
low:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
high:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
required:
|
||||
- low
|
||||
- high
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest
|
||||
reading in the window. Null when neither is known."
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TimelineLabPoint"
|
||||
description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A
|
||||
day with two readings has two points."
|
||||
excluded:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points`
|
||||
rather than drawn on a scale they are not in.
|
||||
required:
|
||||
- key
|
||||
- biomarkerId
|
||||
- name
|
||||
- panel
|
||||
- unit
|
||||
- band
|
||||
- points
|
||||
- excluded
|
||||
additionalProperties: false
|
||||
TimelineLabPoint:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The lab result's id.
|
||||
date:
|
||||
type: string
|
||||
description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under.
|
||||
value:
|
||||
type: number
|
||||
description: In the series' `unit`.
|
||||
sourceValue:
|
||||
description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision
|
||||
`value` is shown with. Null for a reading stored as stated.
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
rangeStatus:
|
||||
type: string
|
||||
enum:
|
||||
- in-range
|
||||
- below
|
||||
- above
|
||||
- unknown
|
||||
description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.
|
||||
referenceLow:
|
||||
description: "The window this reading is judged against, in the series' unit: the one its report printed, else the
|
||||
marker's own."
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
referenceHigh:
|
||||
type:
|
||||
- number
|
||||
- "null"
|
||||
report:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
title:
|
||||
description: The document's title, else its file name.
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
href:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- href
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: The document the reading was read from, when one is linked and the caller may see documents.
|
||||
required:
|
||||
- id
|
||||
- date
|
||||
- value
|
||||
- sourceValue
|
||||
- rangeStatus
|
||||
- referenceLow
|
||||
- referenceHigh
|
||||
- report
|
||||
additionalProperties: false
|
||||
TimelineLabMarker:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: "`lab:<biomarkerId>`, to name in `values`."
|
||||
biomarkerId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
panel:
|
||||
description: The marker's group, as the labs list shows it.
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
unit:
|
||||
type: string
|
||||
required:
|
||||
- key
|
||||
- biomarkerId
|
||||
- name
|
||||
- panel
|
||||
- unit
|
||||
additionalProperties: false
|
||||
TimelineReadinessEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -642,9 +642,23 @@ The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _
|
||||
|
||||
The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-codex` → `gpt-5.3-codex` happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in `CODEX_MODEL` flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein."
|
||||
|
||||
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
|
||||
To eliminate that risk class, HealthLog's `CodexClient` tries its sign-in's working slug first and otherwise walks an ordered chain built from the models the account is offered.
|
||||
|
||||
**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
|
||||
**The account's model list.** Before it walks, the client reads the list the Codex CLI reads, on the same sign-in as the responses call:
|
||||
|
||||
```http
|
||||
GET /backend-api/codex/models?client_version=0.165.0 HTTP/1.1
|
||||
Host: chatgpt.com
|
||||
Authorization: Bearer <oauth-access-token>
|
||||
ChatGPT-Account-ID: <chatgpt_account_id>
|
||||
originator: healthlog
|
||||
```
|
||||
|
||||
The answer is `ModelsResponse` from `codex-rs/protocol/src/openai_models.rs`: `{ "models": [ModelInfo, ...] }`, where the client reads `slug`, `visibility` (`list` / `hide` / `none`), `priority` (lower first), `input_modalities` (`text`, `image`, `audio`) and `model_specialty`. `codex-rs/codex-api/src/endpoint/models.rs` appends `client_version`; the backend leaves out models whose `minimal_client_version` is above it, so the version sent tracks a current Codex CLI release (`CODEX_MODELS_CLIENT_VERSION` in `src/lib/ai/codex-model-catalog.ts`). The read is bounded like the CLI's (5 s, `codex-rs/model-provider/src/models_endpoint.rs`) and to 4 MiB, cached per sign-in for an hour, and a failed read waits ten minutes before the next attempt. The list is a hint, never a gate: a read that fails, times out, does not parse or comes back empty leaves the static chain below, and one `ai.codex.models_unreadable` warning per sign-in reports the stretch.
|
||||
|
||||
**Order.** `CODEX_MODEL` first when set. `CODEX_MODEL_FALLBACK_CHAIN`, when set, replaces everything after it and the list is not read. Otherwise: the default chain below kept to what the account is offered; then every other offered model in the list's `priority` order, leaving out what the list hides (`visibility` other than `list`), the specialist lines (`model_specialty`) and the `-astra` frontier tier; then the rest of the default chain, so the walk never holds fewer models than before the list existed. `supported_in_api` is not a filter: it describes API-key access, and the CLI ignores it for a ChatGPT sign-in (`ModelPreset::filter_by_auth`). A slug this sign-in was refused in the last six hours goes to the very end, even while its list still offers it. With the list's `input_modalities` for the slug in hand, the Codex vision gate reads them; without, it falls back to the model family.
|
||||
|
||||
**Default chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default:
|
||||
|
||||
```ts
|
||||
const DEFAULT_SLUG_FALLBACK_CHAIN = [
|
||||
@@ -662,9 +676,9 @@ Every default rung must be a model family the reasoning, sampling and vision gat
|
||||
|
||||
The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
|
||||
|
||||
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. Reading the account's live list (`GET /backend-api/codex/models`, as the Codex CLI does) would avoid this.
|
||||
A fixed chain goes stale. From 2026-10-14, when GPT-5.5 retired, every model on the chain HealthLog shipped then was retired, and every Codex request answered 503 "all configured Codex slugs were rejected" until the chain was updated or `CODEX_MODEL` was set by hand. That is why the account's live list now comes first and the default chain only orders and backs it up.
|
||||
|
||||
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
|
||||
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated), which replaces it outright; if `CODEX_MODEL` is also set, it is folded into position 0 and de-duplicated.
|
||||
|
||||
**Trigger condition** (when to walk to the next slug):
|
||||
|
||||
@@ -678,15 +692,15 @@ The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-sep
|
||||
| `401` after refresh, `403`, `429`, `5xx` | propagate (don't walk) |
|
||||
| `200` with SSE `response.failed.error.code === "invalid_prompt"` | propagate (request shape is wrong, slug is fine) |
|
||||
|
||||
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write).
|
||||
**Positive cache** — when a slug is observed working (HTTP 200 with at least one `response.completed` event), record `(slug, timestamp)` for that sign-in. On its next request, jump straight to the cached slug; the model list is read only when there is none or it stops working. TTL: 1 hour. Scope: per stored credential (`user:<id>` for a person's connection, `central` for the operator-shared one), process-local, no DB write, at most 1000 sign-ins held. Each entry carries a digest of the ChatGPT account id it was learned under, and a client reads it only under that account, so a credential reconnected to another account starts empty: its first request tries no slug, skips no refusal and trusts no list the old account left, in any process. The vision gate holds no account id; the connect and disconnect routes rebind or drop the entry, so in the process that handled the change it answers from the new sign-in at once, and elsewhere from that process's first request on it. Two sign-ins on different plans no longer overwrite each other's working slug.
|
||||
|
||||
**Cache invalidation**: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds.
|
||||
**Cache invalidation**: on a 400/404 walk the refused slug leaves the working slot and goes to the back of that sign-in's line for six hours. The next-best slug is promoted into the cache only after it succeeds.
|
||||
|
||||
**All-failed case**: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with `httpStatus: 503` and message `"AI provider unreachable — all configured Codex slugs were rejected"`. The route layer surfaces this to the user with "AI provider unreachable — check Settings".
|
||||
|
||||
**Observability**: each attempted slug is recorded in the Wide-Event annotation `codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"]` so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit `codex.slug.cache: "hit" | "miss" | "expired"`.
|
||||
**Observability**: a request that builds the chain annotates `codex.models.chain` (`operator` / `live` / `static`), `codex.models.read` (`live` / `cache` / `skipped`), `codex.models.failure` (`timeout`, `network`, `auth`, `http_error`, `too_large`, `unparsable`, `empty`, or null) and `codex.models.offered` (a count). `CodexClient.getLastDiagnostics()` holds the attempted slugs, the cache state and the working slug.
|
||||
|
||||
The implementation lives in `src/lib/ai/codex-client.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-client.test.ts`.
|
||||
The walk lives in `src/lib/ai/codex-client.ts`, the order in `src/lib/ai/codex-slug-chain.ts`, the list read in `src/lib/ai/codex-model-catalog.ts`, the per-sign-in memory in `src/lib/ai/codex-slug-cache.ts`; the contract is unit-tested in `src/lib/ai/__tests__/codex-live-models.test.ts`, `codex-slug-chain.test.ts`, `codex-model-catalog.test.ts` and `codex-slug-fallback.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
@@ -781,7 +795,10 @@ All paths relative to `openai/codex` repo:
|
||||
- `codex-rs/codex-api/src/rate_limits.rs` — rate-limit headers
|
||||
- `codex-rs/codex-api/src/auth.rs` — `AuthProvider` trait
|
||||
- `codex-rs/protocol/src/models.rs` — `ResponseItem`, `ContentItem`, `MessagePhase`, `ImageDetail`, etc.
|
||||
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum
|
||||
- `codex-rs/protocol/src/openai_models.rs` — `ReasoningEffort` enum; `ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`, `ModelPreset::filter_by_auth` (the `/models` wire)
|
||||
- `codex-rs/codex-api/src/endpoint/models.rs` — `ModelsClient`: `GET models?client_version=…`
|
||||
- `codex-rs/model-provider/src/models_endpoint.rs` — the 5 s model-list bound; `models_identity.rs` — the per-account cache identity
|
||||
- `codex-rs/models-manager/src/manager.rs` — priority sort, picker visibility, cache TTL
|
||||
- `codex-rs/protocol/src/config_types.rs` — `ReasoningSummary`, `Verbosity` enums
|
||||
- `codex-rs/model-provider/src/bearer_auth_provider.rs` — `Authorization` + `ChatGPT-Account-ID` headers
|
||||
- `codex-rs/model-provider/src/auth.rs` — auth-provider selection
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
HealthLog keeps copies of each account so that one account can be rolled back
|
||||
on this server. These copies are per account. They cannot rebuild a lost
|
||||
server: a restore writes into an account that already exists here under the
|
||||
same id, and refuses a copy whose account is gone. To recover a server, you
|
||||
need a database dump and the encryption keys. That procedure is in
|
||||
server: a restore writes into an account on this server, the one the copy
|
||||
belongs to, or, when that account has been erased, one you choose (see
|
||||
"Restoring an erased account"). To recover a server, you need a database dump
|
||||
and the encryption keys. That procedure is in
|
||||
[disaster-recovery.md](disaster-recovery.md).
|
||||
|
||||
## What exists
|
||||
@@ -81,13 +82,75 @@ pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restor
|
||||
```
|
||||
|
||||
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
|
||||
compression) and restore it from its row. The account `<id>` must exist on this
|
||||
server.
|
||||
compression) and restore it from its row. When the account `<id>` no longer
|
||||
exists on this server, the upload offers to put the copy into another account
|
||||
(next section).
|
||||
|
||||
Every restore, preview and upload checks first that this server holds every
|
||||
application key the copy's content was written under, and refuses with the key
|
||||
id named if not. Nothing is changed by a refused restore.
|
||||
|
||||
### Restoring an erased account
|
||||
|
||||
Once an account has been erased (its deletion window ended and the hourly purge
|
||||
ran), its copies on this server and in the bucket are gone, but a file you kept
|
||||
still holds the record: a disaster-recovery download, an off-host object you
|
||||
fetched before it expired, or the person's own full backup. Uploading it in
|
||||
Admin → Backups is refused at first, because the account it names no longer
|
||||
exists, and the console then asks where the records should go:
|
||||
|
||||
- **A new account.** Type a username. The account is created without a
|
||||
password or email, as an ordinary account with the user role, and nobody
|
||||
can sign into it yet. Before handing it over, set its first password with
|
||||
**Reset password** in Admin → Users; the person can change it, add an email
|
||||
and enrol a passkey from their settings. The account is made before the
|
||||
file is read, with an `admin.user.create` audit row (`outcome: "created"`),
|
||||
and removed again when the file is refused, with an `admin.user.delete` row
|
||||
(`reason: "backup_upload_refused"`). When that row says `outcome: "failed"`,
|
||||
the removal did not happen and the account is left over, holding nothing:
|
||||
delete it in Admin → Users.
|
||||
- **An existing account**, for someone who has signed up again. Pick the
|
||||
account and type its username to confirm. The account must hold no records
|
||||
yet: a restore replaces an account's records and does not merge them, so an
|
||||
account with even one record of any kind a copy carries is refused, with the
|
||||
kinds named. What signing up and the setup questions leave behind does not
|
||||
count (the setup answers and the cycle settings): the restore replaces both
|
||||
with the copy's. A reading or medication entered on setup's last screen is a
|
||||
record, and the account is refused until it is deleted, because the restore
|
||||
would delete it too. Managed profiles, accounts pending deletion and admin
|
||||
accounts are not offered: an admin account is the operator's, its settings
|
||||
are read under the operator's wider AI endpoint policy, and its role reaches
|
||||
every account, so another person's record does not go into it.
|
||||
|
||||
The file is then uploaded a second time, and its owner is changed to the chosen
|
||||
account as it is read. Nothing else in the file changes. The stored copy is
|
||||
then that account's copy in every respect but one: its preview, its download
|
||||
and its restore are the ordinary ones, and **Restore** on its row brings
|
||||
everything back under the chosen account. The one difference is that it is
|
||||
stored as `RETARGETED_UPLOAD_<time>` rather than `MANUAL_UPLOAD_<time>`, and its
|
||||
restore reads the file's settings as the file's author's, never as the
|
||||
operator's: an AI endpoint on a private address comes back only when this host
|
||||
grants it to ordinary accounts (`AI_PRIVATE_ORIGINS`), even if the account has
|
||||
been made an admin since. The erased account's id is not stored, not in
|
||||
the copy and not in the audit trail.
|
||||
|
||||
A copy whose own account still exists on this server can only go back into
|
||||
that account; choosing a target for it is refused. The copy does not carry
|
||||
what a restore never touches (below), so a returning person signs in fresh,
|
||||
and the links of a managed profile to its guardians do not come back: a
|
||||
restored managed profile is an ordinary account.
|
||||
|
||||
Once stored, the copy restores like any other: a restore run later, after the
|
||||
account has gained records, replaces those too.
|
||||
|
||||
A file goes into one account only. Its records keep the ids they had in the
|
||||
erased account, so once it has been restored into one account, restoring the
|
||||
same file into a second account would write those ids twice. The upload still
|
||||
stores the second copy, but its restore is refused with
|
||||
`backup.restore.rows_exist_elsewhere` and rolled back, and nothing changes in
|
||||
either account. To move the record to another account, delete the first
|
||||
account (or its records) before restoring into the second.
|
||||
|
||||
### What a restore does not touch
|
||||
|
||||
API tokens, trusted devices, step-up elevations, known devices, clinician share
|
||||
|
||||
+99
-1
@@ -15,12 +15,16 @@
|
||||
* to six lines.
|
||||
* 6. On a phone the chronicle replaces the chart, says its empty months
|
||||
* out loud, names each quarter's means once, and a row opens its day.
|
||||
* 7. A lab marker (#1299) is picked from the values menu, drawn as its
|
||||
* single readings with the range behind them, read on its own, kept
|
||||
* across a reload, and listed on its lab day on a phone.
|
||||
*
|
||||
* The timeline's server routes are mocked with the contract's shapes
|
||||
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
|
||||
* assertion is addressed to a stable `data-*` attribute. The journey moves
|
||||
* its account's module switch, so it runs serially and in one project.
|
||||
*/
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
@@ -298,7 +302,9 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
|
||||
|
||||
// The picker is one button that counts the lines; the chosen ones are
|
||||
// not listed beside it.
|
||||
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
|
||||
const trigger = page.locator(
|
||||
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
|
||||
);
|
||||
await expect(trigger).toHaveAttribute("data-count", "3");
|
||||
await expect(trigger).toHaveText(/\(3\)/);
|
||||
const toolbarHeight = await trigger.evaluate(
|
||||
@@ -811,6 +817,98 @@ test("a value point reads on its own, by pointer and by keyboard", async ({
|
||||
await expect(tip).toHaveAttribute("data-series", series!);
|
||||
});
|
||||
|
||||
test("a lab marker is picked, drawn as its readings, read on its own and kept", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setTimelineModuleOn(page);
|
||||
const log = await mockTimeline(page, "full", TODAY, { labs: true });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/timeline?zoom=all");
|
||||
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page
|
||||
.locator(
|
||||
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
|
||||
)
|
||||
.click();
|
||||
const menu = page.locator('[data-slot="timeline-values-menu"]');
|
||||
await expect(
|
||||
menu.locator('[data-slot="timeline-values-labs"]'),
|
||||
).toBeVisible();
|
||||
await expect(menu.getByText("Eisen")).toBeVisible();
|
||||
await menu
|
||||
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
// The menu hands focus back to its trigger once its exit animation ends.
|
||||
await expect(menu).toBeHidden();
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
|
||||
),
|
||||
).toBeFocused();
|
||||
|
||||
const row = page.locator('[data-lab-series="lab:bm-ferritin"]');
|
||||
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4);
|
||||
// Below its range: a triangle, never only a colour.
|
||||
await expect(row.locator('path[data-status="below"]')).toHaveCount(1);
|
||||
await expect(row.locator('[data-slot="timeline-lab-band-fill"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
|
||||
"lab:bm-ferritin",
|
||||
);
|
||||
|
||||
// The reading below its range, by keyboard: day, value, verdict, report.
|
||||
const target = page.locator(
|
||||
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
|
||||
);
|
||||
await target.focus();
|
||||
const tip = page.locator('[data-slot="timeline-point-tip"]');
|
||||
await expect(tip).toHaveAttribute("data-t", "fe-1");
|
||||
await expect(tip).toContainText("22 ng/mL");
|
||||
await expect(tip).toContainText("Laborbefund März");
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[data-slot="timeline-page"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
|
||||
|
||||
// The choice is kept: a reload asks for the marker again.
|
||||
await page.reload();
|
||||
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// On a phone: the same line, listed on its lab days, no sideways scroll.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const labs = page.locator('[data-slot="timeline-chronicle-labs"]');
|
||||
await expect(labs.first()).toBeVisible();
|
||||
await expect(labs).toHaveCount(4);
|
||||
await expect(labs.last()).toContainText("22 ng/mL");
|
||||
const overflow = await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth -
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
|
||||
// Unchosen, it is gone from the request and the page.
|
||||
await page
|
||||
.locator(
|
||||
'[data-slot="timeline-mobile"] [data-slot="timeline-values-trigger"]',
|
||||
)
|
||||
.click();
|
||||
await page
|
||||
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(labs).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the timeline page ends where its content does, at every desktop width", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
+118
-2
@@ -512,10 +512,104 @@ export function fullTimeline(today: string) {
|
||||
bucket: "quarter" as Bucket,
|
||||
series: series("2019-01-01", today, "quarter"),
|
||||
availableSeries: FULL_AVAILABLE,
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two lab markers of the full record (#1299): ferritin read a few times over
|
||||
* the years against a 30 to 400 range, one reading below it and one read
|
||||
* from a report, and HbA1c read once. Only what `values` names is sent as a
|
||||
* line; the menu offers both.
|
||||
*/
|
||||
export const LAB_MARKERS = [
|
||||
{
|
||||
key: "lab:bm-ferritin",
|
||||
biomarkerId: "bm-ferritin",
|
||||
name: "Ferritin",
|
||||
panel: "Eisen",
|
||||
unit: "ng/mL",
|
||||
},
|
||||
{
|
||||
key: "lab:bm-hba1c",
|
||||
biomarkerId: "bm-hba1c",
|
||||
name: "HbA1c",
|
||||
panel: "Zucker",
|
||||
unit: "%",
|
||||
},
|
||||
];
|
||||
|
||||
function labPoint(
|
||||
id: string,
|
||||
date: string,
|
||||
value: number,
|
||||
rangeStatus: "below" | "in-range" | "above" | "unknown",
|
||||
report: { id: string; title: string } | null = null,
|
||||
) {
|
||||
const ranged = rangeStatus !== "unknown";
|
||||
return {
|
||||
id,
|
||||
date,
|
||||
value,
|
||||
sourceValue: null,
|
||||
rangeStatus,
|
||||
referenceLow: ranged ? 30 : null,
|
||||
referenceHigh: ranged ? 400 : null,
|
||||
report: report ? { ...report, href: `/documents?doc=${report.id}` } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const LAB_LINES = [
|
||||
{
|
||||
...LAB_MARKERS[0],
|
||||
band: { low: 30, high: 400 },
|
||||
points: [
|
||||
labPoint("fe-1", "2021-03-15", 22, "below", {
|
||||
id: "doc-lab-1",
|
||||
title: "Laborbefund März",
|
||||
}),
|
||||
labPoint("fe-2", "2022-09-20", 64, "in-range"),
|
||||
labPoint("fe-3", "2024-02-12", 118, "in-range"),
|
||||
labPoint("fe-4", "2025-11-04", 96, "in-range"),
|
||||
],
|
||||
excluded: 0,
|
||||
},
|
||||
{
|
||||
...LAB_MARKERS[1],
|
||||
band: null,
|
||||
points: [labPoint("a1c-1", "2025-11-04", 5.4, "unknown")],
|
||||
excluded: 0,
|
||||
},
|
||||
];
|
||||
|
||||
/** The lab days behind the lab lines, as the labs lane names them. */
|
||||
function labsLane(keys: readonly string[]) {
|
||||
const days = new Map<string, string[]>();
|
||||
for (const line of LAB_LINES) {
|
||||
if (!keys.includes(line.key)) continue;
|
||||
for (const p of line.points) {
|
||||
days.set(p.date, [...(days.get(p.date) ?? []), line.name]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: "labs",
|
||||
items: [...days]
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([date, names]) =>
|
||||
item({
|
||||
id: `lab-day:${date}`,
|
||||
kind: "labDay",
|
||||
start: date,
|
||||
label: names.join(", "),
|
||||
sub: String(names.length),
|
||||
href: "/labs",
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function readyTimeline(today: string) {
|
||||
return {
|
||||
zoom: "all",
|
||||
@@ -637,6 +731,8 @@ export function readyTimeline(today: string) {
|
||||
(s) => s.key !== "RESTING_HEART_RATE",
|
||||
),
|
||||
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
@@ -834,7 +930,12 @@ export async function mockTimeline(
|
||||
page: Page,
|
||||
state: "full" | "ready",
|
||||
today: string,
|
||||
options: { onlyOld?: boolean; dataFrom?: string } = {},
|
||||
options: {
|
||||
onlyOld?: boolean;
|
||||
dataFrom?: string;
|
||||
/** The record has lab markers to offer and draw (#1299). */
|
||||
labs?: boolean;
|
||||
} = {},
|
||||
): Promise<TimelineMockLog> {
|
||||
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
|
||||
await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) =>
|
||||
@@ -844,9 +945,23 @@ export async function mockTimeline(
|
||||
const url = new URL(route.request().url());
|
||||
log.timelineQueries.push(url.searchParams);
|
||||
const body = state === "full" ? fullTimeline(today) : readyTimeline(today);
|
||||
const values = (url.searchParams.get("values") ?? "")
|
||||
const named = (url.searchParams.get("values") ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
// A lab marker is a line of its own (`labSeries`), never a mean.
|
||||
const labKeys = named.filter((key) => key.startsWith("lab:"));
|
||||
const values = named.filter((key) => !key.startsWith("lab:"));
|
||||
const labs =
|
||||
state === "full" && options.labs
|
||||
? {
|
||||
labSeries: LAB_LINES.filter((l) => labKeys.includes(l.key)),
|
||||
availableLabs: LAB_MARKERS,
|
||||
lanes:
|
||||
labKeys.length > 0
|
||||
? [...body.lanes, labsLane(labKeys)]
|
||||
: body.lanes,
|
||||
}
|
||||
: {};
|
||||
const zoom = url.searchParams.get("zoom") ?? "all";
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
@@ -872,6 +987,7 @@ export async function mockTimeline(
|
||||
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
|
||||
return json(route, 200, {
|
||||
...body,
|
||||
...labs,
|
||||
zoom,
|
||||
bucket,
|
||||
...(chosen
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
"downloadStarted": "Download gestartet",
|
||||
"uploadTitle": "Backup hochladen",
|
||||
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Das Konto, zu dem sie gehört, muss auf diesem Server existieren. Das Wiederherstellen ist ein eigener Schritt.",
|
||||
"uploadDescription": "Prüft eine Kontokopie und speichert sie neben den Kopien dieses Servers. Eine Kopie, deren Konto es auf diesem Server nicht mehr gibt, kann in ein anderes Konto gehen. Das Wiederherstellen ist ein eigener Schritt.",
|
||||
"uploadButton": "Datei hochladen",
|
||||
"uploadHelp": "HealthLog-Backup als .json oder .json.gz. Die Datei wird vor dem Hochladen komprimiert; komprimiert darf sie bis zu 512 MB groß sein.",
|
||||
"uploadSuccess": "Backup hochgeladen ({count} Datensätze)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.",
|
||||
"restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.",
|
||||
"restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.",
|
||||
"restoreFailureRowsElsewhere": "Die Datensätze dieser Datei liegen schon in einem anderen Konto auf diesem Server: Dieselbe Datei wurde dort bereits wiederhergestellt. Eine Datei lässt sich nur in ein Konto wiederherstellen. Es wurde nichts geschrieben.",
|
||||
"previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)",
|
||||
"restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "Der neueste Dump im Bucket stammt vom {when}.",
|
||||
"offhostNeverUploaded": "Bisher ist kein Datenbank-Dump im Bucket angekommen."
|
||||
},
|
||||
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück."
|
||||
"restorePreWipeLimits": "Diese Kopie wurde vor dem Löschen der Daten des Kontos angelegt, und ihre Wiederherstellung macht das Löschen rückgängig, soweit eine Kopie das kann: Was das Konto seitdem hinzugefügt hat, wird ersetzt, und Integrationsverbindungen, Freigaben, Betreuungszugriffe und Freigabe-Links kommen nicht zurück.",
|
||||
"target": {
|
||||
"title": "In ein anderes Konto wiederherstellen",
|
||||
"description": "{file} gehört zu einem Konto, das es auf diesem Server nicht mehr gibt. Wähle das Konto, in das die Datensätze gehen. Eine Wiederherstellung ersetzt die Datensätze eines Kontos, deshalb kann sie nur ein Konto ohne Datensätze aufnehmen. Die Datei wird dafür noch einmal hochgeladen.",
|
||||
"choiceLabel": "Wohin die Datensätze gehen",
|
||||
"newAccount": "Neues Konto",
|
||||
"existingAccount": "Bestehendes Konto",
|
||||
"usernameLabel": "Benutzername für das neue Konto",
|
||||
"usernameInvalid": "3 bis 30 Buchstaben, Ziffern, _ oder -, nicht mit „managed-“ am Anfang.",
|
||||
"newAccountHint": "Das Konto wird ohne Passwort und ohne E-Mail angelegt. Bevor du es übergibst, setze sein erstes Passwort unter Nutzer mit „Passwort zurücksetzen“.",
|
||||
"accountLabel": "Konto",
|
||||
"accountPlaceholder": "Konto wählen",
|
||||
"checking": "Konto wird geprüft …",
|
||||
"eligible": "Dieses Konto hat noch keine Datensätze und kann die Kopie aufnehmen.",
|
||||
"confirmLabel": "Tippe {username} zum Bestätigen",
|
||||
"submit": "In dieses Konto hochladen",
|
||||
"submitting": "Wird hochgeladen …",
|
||||
"success": "Backup für {username} gespeichert ({count} Datensätze). Stelle es über seine Zeile wieder her.",
|
||||
"failureHoldsRecords": "Dieses Konto hat schon Datensätze. Eine Wiederherstellung ersetzt Datensätze, statt sie zu ergänzen. Wähle ein Konto ohne Datensätze oder lege ein neues an.",
|
||||
"failureManagedProfile": "Dieses Konto ist ein verwaltetes Profil. Die betreuenden Personen führen es, deshalb kann keine Kopie hinein.",
|
||||
"failurePendingDeletion": "Dieses Konto wird gerade gelöscht. Mach die Löschung zuerst rückgängig oder wähle ein anderes Konto.",
|
||||
"failureAdminAccount": "Das ist ein Admin-Konto. Die Datensätze einer anderen Person können nicht hinein. Wähle ein normales Konto oder lege ein neues an.",
|
||||
"failureNotFound": "Dieses Konto gibt es nicht mehr.",
|
||||
"failureUnconfirmed": "Tippe den Benutzernamen des Kontos genau ab, um zu bestätigen.",
|
||||
"failureUsernameTaken": "Dieser Benutzername ist schon vergeben.",
|
||||
"failureOwnerExists": "Das Konto, zu dem diese Datei gehört, ist auf diesem Server, und die Datei wird in dieses Konto wiederhergestellt. Lade sie noch einmal hoch, ohne ein Konto zu wählen."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Beginn",
|
||||
"end": "Ende",
|
||||
"seriesCaption": "Mittelwerte nach Zeitraum",
|
||||
"period": "Zeitraum"
|
||||
"period": "Zeitraum",
|
||||
"labsCaption": "Laborwerte nach Tag",
|
||||
"marker": "Marker",
|
||||
"date": "Datum",
|
||||
"value": "Wert"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Keine Einträge in diesem Zeitraum",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 Laborwert",
|
||||
"labValuesFew": "{count} Laborwerte",
|
||||
"labValuesOther": "{count} Laborwerte"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Laborwerte",
|
||||
"otherPanel": "Weitere",
|
||||
"full": "Bis zu {max} Laborwerte gleichzeitig. Entferne einen, um einen anderen hinzuzufügen.",
|
||||
"above": "über dem Referenzbereich",
|
||||
"below": "unter dem Referenzbereich",
|
||||
"reference": "Referenz {range}",
|
||||
"report": "Befund: {title}",
|
||||
"reportUntitled": "Befund öffnen",
|
||||
"excludedOne": "{name}: {count} Wert in einer anderen Einheit ist nicht eingezeichnet",
|
||||
"excludedFew": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet",
|
||||
"excludedOther": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet"
|
||||
},
|
||||
"legendLabValue": "Laborwert, wie gemessen",
|
||||
"legendLabOutside": "Außerhalb des Referenzbereichs",
|
||||
"legendLabRange": "Referenzbereich"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Lebensereignis",
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started",
|
||||
"uploadTitle": "Upload backup",
|
||||
"uploadDescription": "Check an account copy and store it beside this server's copies. The account it belongs to must exist on this server. Restoring is a separate step.",
|
||||
"uploadDescription": "Check an account copy and store it beside this server's copies. A copy whose account is no longer on this server can go into another account. Restoring is a separate step.",
|
||||
"uploadButton": "Upload file",
|
||||
"uploadHelp": "A HealthLog backup as .json or .json.gz. The file is compressed before upload; compressed, it may be up to 512 MB.",
|
||||
"uploadSuccess": "Backup uploaded ({count} records)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.",
|
||||
"restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.",
|
||||
"restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.",
|
||||
"restoreFailureRowsElsewhere": "The records in this file are already in another account on this server: the same file was restored there before. A file can be restored into one account only. Nothing was written.",
|
||||
"previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)",
|
||||
"restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "The newest dump in the bucket is from {when}.",
|
||||
"offhostNeverUploaded": "No database dump has reached the bucket yet."
|
||||
},
|
||||
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back."
|
||||
"restorePreWipeLimits": "This copy was taken before the account's data was deleted, and restoring it undoes that deletion as far as a copy can: whatever the account added since is replaced, and integration connections, sharing and Guardian access, and share links do not come back.",
|
||||
"target": {
|
||||
"title": "Restore into another account",
|
||||
"description": "{file} belongs to an account that is no longer on this server. Choose the account its records go into. A restore replaces an account's records, so only an account without records can take them. The file is uploaded once more.",
|
||||
"choiceLabel": "Where the records go",
|
||||
"newAccount": "New account",
|
||||
"existingAccount": "Existing account",
|
||||
"usernameLabel": "Username for the new account",
|
||||
"usernameInvalid": "3 to 30 letters, digits, _ or -, not starting with \"managed-\".",
|
||||
"newAccountHint": "The account is created without a password or email. Before you hand it over, set its first password with Reset password under Users.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Choose an account",
|
||||
"checking": "Checking the account…",
|
||||
"eligible": "This account holds no records yet and can take the copy.",
|
||||
"confirmLabel": "Type {username} to confirm",
|
||||
"submit": "Upload into this account",
|
||||
"submitting": "Uploading…",
|
||||
"success": "Backup stored for {username} ({count} records). Restore it from its row.",
|
||||
"failureHoldsRecords": "This account already holds records. A restore replaces records instead of adding to them, so choose an account without records or create a new one.",
|
||||
"failureManagedProfile": "This account is a managed profile. The people who look after it run it, so a copy cannot go into it.",
|
||||
"failurePendingDeletion": "This account is pending deletion. Undo the deletion first, or choose another account.",
|
||||
"failureAdminAccount": "This is an admin account. Another person's records cannot go into it. Choose an ordinary account or create a new one.",
|
||||
"failureNotFound": "This account no longer exists.",
|
||||
"failureUnconfirmed": "Type the account's username exactly to confirm.",
|
||||
"failureUsernameTaken": "This username is already taken.",
|
||||
"failureOwnerExists": "The account this file belongs to is on this server, and the file restores into that account. Upload it again without choosing an account."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger zone",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Start",
|
||||
"end": "End",
|
||||
"seriesCaption": "Value means by period",
|
||||
"period": "Period"
|
||||
"period": "Period",
|
||||
"labsCaption": "Lab values by day",
|
||||
"marker": "Marker",
|
||||
"date": "Date",
|
||||
"value": "Value"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "No entries in this period",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 lab value",
|
||||
"labValuesFew": "{count} lab values",
|
||||
"labValuesOther": "{count} lab values"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Lab values",
|
||||
"otherPanel": "Other",
|
||||
"full": "Up to {max} lab values at once. Remove one to add another.",
|
||||
"above": "above range",
|
||||
"below": "below range",
|
||||
"reference": "reference {range}",
|
||||
"report": "Report: {title}",
|
||||
"reportUntitled": "Open the report",
|
||||
"excludedOne": "{name}: {count} value in another unit is not drawn",
|
||||
"excludedFew": "{name}: {count} values in another unit are not drawn",
|
||||
"excludedOther": "{name}: {count} values in another unit are not drawn"
|
||||
},
|
||||
"legendLabValue": "Lab value, as measured",
|
||||
"legendLabOutside": "Outside the reference range",
|
||||
"legendLabRange": "Reference range"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Life event",
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "La descarga falló",
|
||||
"downloadStarted": "Descarga iniciada",
|
||||
"uploadTitle": "Subir copia de seguridad",
|
||||
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. La cuenta a la que pertenece debe existir en este servidor. Restaurar es un paso aparte.",
|
||||
"uploadDescription": "Comprueba una copia de cuenta y la guarda junto a las copias de este servidor. Una copia cuya cuenta ya no está en este servidor puede ir a otra cuenta. Restaurar es un paso aparte.",
|
||||
"uploadButton": "Subir archivo",
|
||||
"uploadHelp": "Una copia de seguridad de HealthLog en .json o .json.gz. El archivo se comprime antes de subirlo; comprimido puede ocupar hasta 512 MB.",
|
||||
"uploadSuccess": "Copia de seguridad subida ({count} registros)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.",
|
||||
"restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.",
|
||||
"restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.",
|
||||
"restoreFailureRowsElsewhere": "Los registros de este archivo ya están en otra cuenta de este servidor: el mismo archivo ya se restauró allí. Un archivo solo puede restaurarse en una cuenta. No se escribió nada.",
|
||||
"previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)",
|
||||
"restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "El volcado más reciente del bucket es del {when}.",
|
||||
"offhostNeverUploaded": "Todavía no ha llegado ningún volcado de la base de datos al bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven."
|
||||
"restorePreWipeLimits": "Esta copia se tomó antes de que se eliminaran los datos de la cuenta, y restaurarla deshace esa eliminación hasta donde una copia puede: lo que la cuenta añadió desde entonces se sustituye, y las conexiones de integración, lo compartido, el acceso de personas responsables y los enlaces compartidos no vuelven.",
|
||||
"target": {
|
||||
"title": "Restaurar en otra cuenta",
|
||||
"description": "{file} pertenece a una cuenta que ya no está en este servidor. Elige la cuenta a la que van sus registros. Una restauración reemplaza los registros de una cuenta, así que solo una cuenta sin registros puede recibirlos. El archivo se sube una vez más.",
|
||||
"choiceLabel": "Adónde van los registros",
|
||||
"newAccount": "Cuenta nueva",
|
||||
"existingAccount": "Cuenta existente",
|
||||
"usernameLabel": "Nombre de usuario de la cuenta nueva",
|
||||
"usernameInvalid": "De 3 a 30 letras, números, _ o -, sin empezar por «managed-».",
|
||||
"newAccountHint": "La cuenta se crea sin contraseña ni correo. Antes de entregarla, pon su primera contraseña con Restablecer contraseña en Usuarios.",
|
||||
"accountLabel": "Cuenta",
|
||||
"accountPlaceholder": "Elige una cuenta",
|
||||
"checking": "Comprobando la cuenta…",
|
||||
"eligible": "Esta cuenta aún no tiene registros y puede recibir la copia.",
|
||||
"confirmLabel": "Escribe {username} para confirmar",
|
||||
"submit": "Subir a esta cuenta",
|
||||
"submitting": "Subiendo…",
|
||||
"success": "Copia guardada para {username} ({count} registros). Restáurala desde su fila.",
|
||||
"failureHoldsRecords": "Esta cuenta ya tiene registros. Una restauración reemplaza los registros en lugar de añadirlos, así que elige una cuenta sin registros o crea una nueva.",
|
||||
"failureManagedProfile": "Esta cuenta es un perfil gestionado. La llevan las personas responsables, así que una copia no puede ir a ella.",
|
||||
"failurePendingDeletion": "Esta cuenta está pendiente de eliminación. Deshaz primero la eliminación o elige otra cuenta.",
|
||||
"failureAdminAccount": "Esta es una cuenta de administrador. Los registros de otra persona no pueden ir en ella, así que elige una cuenta normal o crea una nueva.",
|
||||
"failureNotFound": "Esta cuenta ya no existe.",
|
||||
"failureUnconfirmed": "Escribe exactamente el nombre de usuario de la cuenta para confirmar.",
|
||||
"failureUsernameTaken": "Este nombre de usuario ya está en uso.",
|
||||
"failureOwnerExists": "La cuenta a la que pertenece este archivo está en este servidor, y el archivo se restaura en esa cuenta. Súbelo de nuevo sin elegir una cuenta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Inicio",
|
||||
"end": "Fin",
|
||||
"seriesCaption": "Medias por periodo",
|
||||
"period": "Periodo"
|
||||
"period": "Periodo",
|
||||
"labsCaption": "Valores de laboratorio por día",
|
||||
"marker": "Marcador",
|
||||
"date": "Fecha",
|
||||
"value": "Valor"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "No hay entradas en este periodo",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 valor de laboratorio",
|
||||
"labValuesFew": "{count} valores de laboratorio",
|
||||
"labValuesOther": "{count} valores de laboratorio"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Valores de laboratorio",
|
||||
"otherPanel": "Otros",
|
||||
"full": "Hasta {max} valores de laboratorio a la vez. Quita uno para añadir otro.",
|
||||
"above": "por encima del intervalo",
|
||||
"below": "por debajo del intervalo",
|
||||
"reference": "referencia {range}",
|
||||
"report": "Informe: {title}",
|
||||
"reportUntitled": "Abrir el informe",
|
||||
"excludedOne": "{name}: {count} valor en otra unidad no se muestra",
|
||||
"excludedFew": "{name}: {count} valores en otra unidad no se muestran",
|
||||
"excludedOther": "{name}: {count} valores en otra unidad no se muestran"
|
||||
},
|
||||
"legendLabValue": "Valor de laboratorio, tal como se midió",
|
||||
"legendLabOutside": "Fuera del intervalo de referencia",
|
||||
"legendLabRange": "Intervalo de referencia"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Acontecimiento vital",
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadStarted": "Téléchargement démarré",
|
||||
"uploadTitle": "Téléverser une sauvegarde",
|
||||
"uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Le compte auquel elle appartient doit exister sur ce serveur. La restauration est une étape distincte.",
|
||||
"uploadDescription": "Vérifie une copie de compte et la stocke à côté des copies de ce serveur. Une copie dont le compte n'existe plus sur ce serveur peut aller dans un autre compte. La restauration est une étape distincte.",
|
||||
"uploadButton": "Téléverser un fichier",
|
||||
"uploadHelp": "Une sauvegarde HealthLog au format .json ou .json.gz. Le fichier est compressé avant l’envoi ; compressé, il peut atteindre 512 Mo.",
|
||||
"uploadSuccess": "Sauvegarde téléversée ({count} enregistrements)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur.",
|
||||
"restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.",
|
||||
"restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.",
|
||||
"restoreFailureRowsElsewhere": "Les enregistrements de ce fichier se trouvent déjà dans un autre compte de ce serveur : le même fichier y a déjà été restauré. Un fichier ne peut être restauré que dans un seul compte. Rien n’a été écrit.",
|
||||
"previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)",
|
||||
"restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "Le dump le plus récent du bucket date du {when}.",
|
||||
"offhostNeverUploaded": "Aucun dump de la base de données n'est encore arrivé dans le bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas."
|
||||
"restorePreWipeLimits": "Cette copie a été prise avant la suppression des données du compte, et la restaurer annule cette suppression autant qu’une copie le peut : ce que le compte a ajouté depuis est remplacé, et les connexions d’intégration, les partages, l’accès des personnes responsables et les liens de partage ne reviennent pas.",
|
||||
"target": {
|
||||
"title": "Restaurer dans un autre compte",
|
||||
"description": "{file} appartient à un compte qui n'existe plus sur ce serveur. Choisissez le compte qui recevra ses enregistrements. Une restauration remplace les enregistrements d'un compte : seul un compte sans enregistrements peut donc les recevoir. Le fichier est téléversé une nouvelle fois.",
|
||||
"choiceLabel": "Destination des enregistrements",
|
||||
"newAccount": "Nouveau compte",
|
||||
"existingAccount": "Compte existant",
|
||||
"usernameLabel": "Nom d'utilisateur du nouveau compte",
|
||||
"usernameInvalid": "De 3 à 30 lettres, chiffres, _ ou -, sans commencer par « managed- ».",
|
||||
"newAccountHint": "Le compte est créé sans mot de passe ni e-mail. Avant de le remettre, définissez son premier mot de passe avec Réinitialiser le mot de passe, sous Utilisateurs.",
|
||||
"accountLabel": "Compte",
|
||||
"accountPlaceholder": "Choisissez un compte",
|
||||
"checking": "Vérification du compte…",
|
||||
"eligible": "Ce compte n'a encore aucun enregistrement et peut recevoir la copie.",
|
||||
"confirmLabel": "Tapez {username} pour confirmer",
|
||||
"submit": "Téléverser dans ce compte",
|
||||
"submitting": "Téléversement…",
|
||||
"success": "Sauvegarde enregistrée pour {username} ({count} enregistrements). Restaurez-la depuis sa ligne.",
|
||||
"failureHoldsRecords": "Ce compte contient déjà des enregistrements. Une restauration remplace les enregistrements au lieu de les compléter : choisissez un compte sans enregistrements ou créez-en un nouveau.",
|
||||
"failureManagedProfile": "Ce compte est un profil géré. Les personnes responsables le gèrent, une copie ne peut donc pas y aller.",
|
||||
"failurePendingDeletion": "Ce compte est en cours de suppression. Annulez d'abord la suppression ou choisissez un autre compte.",
|
||||
"failureAdminAccount": "Il s’agit d’un compte administrateur. Les enregistrements d’une autre personne ne peuvent pas y être placés : choisissez un compte ordinaire ou créez-en un nouveau.",
|
||||
"failureNotFound": "Ce compte n'existe plus.",
|
||||
"failureUnconfirmed": "Tapez exactement le nom d'utilisateur du compte pour confirmer.",
|
||||
"failureUsernameTaken": "Ce nom d'utilisateur est déjà pris.",
|
||||
"failureOwnerExists": "Le compte auquel appartient ce fichier est sur ce serveur, et le fichier se restaure dans ce compte. Téléversez-le à nouveau sans choisir de compte."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Début",
|
||||
"end": "Fin",
|
||||
"seriesCaption": "Moyennes par période",
|
||||
"period": "Période"
|
||||
"period": "Période",
|
||||
"labsCaption": "Valeurs de laboratoire par jour",
|
||||
"marker": "Marqueur",
|
||||
"date": "Date",
|
||||
"value": "Valeur"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Aucune entrée sur cette période",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 valeur de laboratoire",
|
||||
"labValuesFew": "{count} valeurs de laboratoire",
|
||||
"labValuesOther": "{count} valeurs de laboratoire"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Valeurs de laboratoire",
|
||||
"otherPanel": "Autres",
|
||||
"full": "Jusqu’à {max} valeurs de laboratoire à la fois. Retirez-en une pour en ajouter une autre.",
|
||||
"above": "au-dessus de l’intervalle",
|
||||
"below": "en dessous de l’intervalle",
|
||||
"reference": "référence {range}",
|
||||
"report": "Compte rendu : {title}",
|
||||
"reportUntitled": "Ouvrir le compte rendu",
|
||||
"excludedOne": "{name} : {count} valeur dans une autre unité n’est pas affichée",
|
||||
"excludedFew": "{name} : {count} valeurs dans une autre unité ne sont pas affichées",
|
||||
"excludedOther": "{name} : {count} valeurs dans une autre unité ne sont pas affichées"
|
||||
},
|
||||
"legendLabValue": "Valeur de laboratoire, telle que mesurée",
|
||||
"legendLabOutside": "Hors de l’intervalle de référence",
|
||||
"legendLabRange": "Intervalle de référence"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Événement de vie",
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Download non riuscito",
|
||||
"downloadStarted": "Download avviato",
|
||||
"uploadTitle": "Carica backup",
|
||||
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. L’account a cui appartiene deve esistere su questo server. Il ripristino è un passaggio a parte.",
|
||||
"uploadDescription": "Controlla una copia dell’account e la conserva accanto alle copie di questo server. Una copia il cui account non è più su questo server può andare in un altro account. Il ripristino è un passaggio a parte.",
|
||||
"uploadButton": "Carica file",
|
||||
"uploadHelp": "Un backup di HealthLog in formato .json o .json.gz. Il file viene compresso prima del caricamento; compresso può arrivare a 512 MB.",
|
||||
"uploadSuccess": "Backup caricato ({count} record)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.",
|
||||
"restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.",
|
||||
"restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.",
|
||||
"restoreFailureRowsElsewhere": "I record di questo file sono già in un altro account di questo server: lo stesso file è già stato ripristinato lì. Un file può essere ripristinato in un solo account. Non è stato scritto nulla.",
|
||||
"previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)",
|
||||
"restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "Il dump più recente nel bucket è del {when}.",
|
||||
"offhostNeverUploaded": "Nessun dump del database è ancora arrivato nel bucket."
|
||||
},
|
||||
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano."
|
||||
"restorePreWipeLimits": "Questa copia è stata fatta prima che i dati dell’account venissero eliminati, e ripristinarla annulla l’eliminazione per quanto una copia può: ciò che l’account ha aggiunto nel frattempo viene sostituito, e le connessioni di integrazione, le condivisioni, l’accesso delle persone responsabili e i link di condivisione non tornano.",
|
||||
"target": {
|
||||
"title": "Ripristina in un altro account",
|
||||
"description": "{file} appartiene a un account che non è più su questo server. Scegli l’account in cui vanno i suoi record. Un ripristino sostituisce i record di un account, quindi solo un account senza record può accoglierli. Il file viene caricato un’altra volta.",
|
||||
"choiceLabel": "Dove vanno i record",
|
||||
"newAccount": "Nuovo account",
|
||||
"existingAccount": "Account esistente",
|
||||
"usernameLabel": "Nome utente del nuovo account",
|
||||
"usernameInvalid": "Da 3 a 30 lettere, cifre, _ o -, senza iniziare con “managed-”.",
|
||||
"newAccountHint": "L’account viene creato senza password né email. Prima di consegnarlo, imposta la sua prima password con Reimposta password in Utenti.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Scegli un account",
|
||||
"checking": "Controllo dell’account…",
|
||||
"eligible": "Questo account non ha ancora record e può accogliere la copia.",
|
||||
"confirmLabel": "Digita {username} per confermare",
|
||||
"submit": "Carica in questo account",
|
||||
"submitting": "Caricamento…",
|
||||
"success": "Backup salvato per {username} ({count} record). Ripristinalo dalla sua riga.",
|
||||
"failureHoldsRecords": "Questo account ha già dei record. Un ripristino sostituisce i record invece di aggiungerli, quindi scegli un account senza record o creane uno nuovo.",
|
||||
"failureManagedProfile": "Questo account è un profilo gestito. Lo gestiscono le persone responsabili, quindi una copia non può andarci.",
|
||||
"failurePendingDeletion": "Questo account è in attesa di eliminazione. Annulla prima l’eliminazione o scegli un altro account.",
|
||||
"failureAdminAccount": "Questo è un account amministratore. I record di un’altra persona non possono andarci, quindi scegli un account normale o creane uno nuovo.",
|
||||
"failureNotFound": "Questo account non esiste più.",
|
||||
"failureUnconfirmed": "Digita esattamente il nome utente dell’account per confermare.",
|
||||
"failureUsernameTaken": "Questo nome utente è già in uso.",
|
||||
"failureOwnerExists": "L’account a cui appartiene questo file è su questo server, e il file si ripristina in quell’account. Caricalo di nuovo senza scegliere un account."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Inizio",
|
||||
"end": "Fine",
|
||||
"seriesCaption": "Medie per periodo",
|
||||
"period": "Periodo"
|
||||
"period": "Periodo",
|
||||
"labsCaption": "Valori di laboratorio per giorno",
|
||||
"marker": "Marcatore",
|
||||
"date": "Data",
|
||||
"value": "Valore"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Nessuna voce in questo periodo",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 valore di laboratorio",
|
||||
"labValuesFew": "{count} valori di laboratorio",
|
||||
"labValuesOther": "{count} valori di laboratorio"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Valori di laboratorio",
|
||||
"otherPanel": "Altri",
|
||||
"full": "Fino a {max} valori di laboratorio alla volta. Rimuovine uno per aggiungerne un altro.",
|
||||
"above": "sopra l’intervallo",
|
||||
"below": "sotto l’intervallo",
|
||||
"reference": "riferimento {range}",
|
||||
"report": "Referto: {title}",
|
||||
"reportUntitled": "Apri il referto",
|
||||
"excludedOne": "{name}: {count} valore in un’altra unità non è riportato",
|
||||
"excludedFew": "{name}: {count} valori in un’altra unità non sono riportati",
|
||||
"excludedOther": "{name}: {count} valori in un’altra unità non sono riportati"
|
||||
},
|
||||
"legendLabValue": "Valore di laboratorio, così come misurato",
|
||||
"legendLabOutside": "Fuori dall’intervallo di riferimento",
|
||||
"legendLabRange": "Intervallo di riferimento"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento di vita",
|
||||
|
||||
+51
-4
@@ -8192,7 +8192,7 @@
|
||||
"downloadFailed": "다운로드하지 못했어요",
|
||||
"downloadStarted": "다운로드를 시작했어요",
|
||||
"uploadTitle": "백업 업로드",
|
||||
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 사본의 계정이 이 서버에 있어야 해요. 복원은 별도 단계예요.",
|
||||
"uploadDescription": "계정 사본을 확인하고 이 서버의 사본 옆에 저장해요. 계정이 이 서버에 더 이상 없는 사본은 다른 계정으로 보낼 수 있어요. 복원은 별도 단계예요.",
|
||||
"uploadButton": "파일 업로드",
|
||||
"uploadHelp": "HealthLog 백업 파일(.json 또는 .json.gz)이에요. 업로드 전에 압축되며, 압축된 크기는 최대 512 MB까지 가능해요.",
|
||||
"uploadSuccess": "백업을 업로드했어요 (기록 {count}개)",
|
||||
@@ -8320,6 +8320,7 @@
|
||||
"restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.",
|
||||
"restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
|
||||
"restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
|
||||
"restoreFailureRowsElsewhere": "이 파일의 기록은 이미 이 서버의 다른 계정에 있습니다. 같은 파일이 그 계정에 먼저 복원되었습니다. 파일은 한 계정에만 복원할 수 있습니다. 아무것도 기록되지 않았습니다.",
|
||||
"previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.",
|
||||
"restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)",
|
||||
"restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)",
|
||||
@@ -8351,7 +8352,33 @@
|
||||
"offhostLastUpload": "버킷에 있는 최신 덤프는 {when}의 것입니다.",
|
||||
"offhostNeverUploaded": "아직 버킷에 도달한 데이터베이스 덤프가 없습니다."
|
||||
},
|
||||
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요."
|
||||
"restorePreWipeLimits": "이 사본은 계정의 데이터를 삭제하기 전에 만들어졌고, 복원하면 사본이 할 수 있는 만큼 그 삭제를 되돌려요: 그 후에 계정에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 접근, 공유 링크는 돌아오지 않아요.",
|
||||
"target": {
|
||||
"title": "다른 계정으로 복원",
|
||||
"description": "{file}은(는) 이 서버에 더 이상 없는 계정의 파일이에요. 기록을 넣을 계정을 골라 주세요. 복원은 계정의 기록을 바꿔 쓰기 때문에 기록이 없는 계정만 받을 수 있어요. 파일은 한 번 더 업로드돼요.",
|
||||
"choiceLabel": "기록을 넣을 곳",
|
||||
"newAccount": "새 계정",
|
||||
"existingAccount": "기존 계정",
|
||||
"usernameLabel": "새 계정의 사용자 이름",
|
||||
"usernameInvalid": "영문자, 숫자, _ 또는 - 3~30자이고, \"managed-\"로 시작하면 안 돼요.",
|
||||
"newAccountHint": "계정은 비밀번호와 이메일 없이 만들어져요. 넘겨주기 전에 사용자에서 비밀번호 재설정으로 첫 비밀번호를 정해 주세요.",
|
||||
"accountLabel": "계정",
|
||||
"accountPlaceholder": "계정을 골라 주세요",
|
||||
"checking": "계정을 확인하는 중…",
|
||||
"eligible": "이 계정에는 아직 기록이 없어서 사본을 받을 수 있어요.",
|
||||
"confirmLabel": "확인하려면 {username}을(를) 입력해 주세요",
|
||||
"submit": "이 계정으로 업로드",
|
||||
"submitting": "업로드 중…",
|
||||
"success": "{username}의 백업을 저장했어요 (기록 {count}개). 해당 행에서 복원해 주세요.",
|
||||
"failureHoldsRecords": "이 계정에는 이미 기록이 있어요. 복원은 기록을 더하지 않고 바꿔 쓰기 때문에 기록이 없는 계정을 고르거나 새 계정을 만들어 주세요.",
|
||||
"failureManagedProfile": "이 계정은 관리 프로필이에요. 보호자들이 운영하는 프로필이라 사본을 넣을 수 없어요.",
|
||||
"failurePendingDeletion": "이 계정은 삭제 대기 중이에요. 먼저 삭제를 취소하거나 다른 계정을 골라 주세요.",
|
||||
"failureAdminAccount": "관리자 계정이에요. 다른 사람의 기록은 이 계정에 넣을 수 없으니 일반 계정을 고르거나 새 계정을 만들어 주세요.",
|
||||
"failureNotFound": "이 계정은 더 이상 없어요.",
|
||||
"failureUnconfirmed": "확인하려면 계정의 사용자 이름을 정확히 입력해 주세요.",
|
||||
"failureUsernameTaken": "이미 사용 중인 사용자 이름이에요.",
|
||||
"failureOwnerExists": "이 파일의 계정이 이 서버에 있어서 파일은 그 계정으로 복원돼요. 계정을 고르지 말고 다시 업로드해 주세요."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "시작",
|
||||
"end": "끝",
|
||||
"seriesCaption": "기간별 평균",
|
||||
"period": "기간"
|
||||
"period": "기간",
|
||||
"labsCaption": "날짜별 검사 수치",
|
||||
"marker": "항목",
|
||||
"date": "날짜",
|
||||
"value": "값"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "이 기간에는 항목이 없습니다",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "검사 수치 1개",
|
||||
"labValuesFew": "검사 수치 {count}개",
|
||||
"labValuesOther": "검사 수치 {count}개"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "검사 수치",
|
||||
"otherPanel": "기타",
|
||||
"full": "검사 수치는 한 번에 최대 {max}개까지 표시할 수 있습니다. 다른 수치를 추가하려면 하나를 제거하세요.",
|
||||
"above": "범위 초과",
|
||||
"below": "범위 미만",
|
||||
"reference": "참고 범위 {range}",
|
||||
"report": "보고서: {title}",
|
||||
"reportUntitled": "보고서 열기",
|
||||
"excludedOne": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다",
|
||||
"excludedFew": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다",
|
||||
"excludedOther": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다"
|
||||
},
|
||||
"legendLabValue": "측정된 그대로의 검사 수치",
|
||||
"legendLabOutside": "참고 범위 밖",
|
||||
"legendLabRange": "참고 범위"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "삶의 사건",
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Downloaden mislukt",
|
||||
"downloadStarted": "Download gestart",
|
||||
"uploadTitle": "Back-up uploaden",
|
||||
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Het account waar ze bij hoort, moet op deze server bestaan. Terugzetten is een aparte stap.",
|
||||
"uploadDescription": "Controleert een accountkopie en bewaart die naast de kopieën van deze server. Een kopie waarvan het account niet meer op deze server staat, kan naar een ander account. Terugzetten is een aparte stap.",
|
||||
"uploadButton": "Bestand uploaden",
|
||||
"uploadHelp": "Een HealthLog-back-up als .json of .json.gz. Het bestand wordt vóór het uploaden gecomprimeerd; gecomprimeerd mag het maximaal 512 MB zijn.",
|
||||
"uploadSuccess": "Back-up geüpload ({count} records)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "Het terugzetten stopte door een onverwachte fout en is teruggedraaid. In het serverlog staat de fout.",
|
||||
"restoreFailureKeyMissing": "De back-up heeft versleutelingssleutel {keys} nodig, waarmee deze server hem niet kan openen. Voeg de sleutel weer toe aan ENCRYPTION_KEYS, herstart en start het terugzetten opnieuw.",
|
||||
"restoreFailureForeignReference": "Het bestand verwijst naar records die bij een ander account horen. Er is niets geschreven.",
|
||||
"restoreFailureRowsElsewhere": "De records in dit bestand staan al in een ander account op deze server: hetzelfde bestand is daar al teruggezet. Een bestand kan maar in één account worden teruggezet. Er is niets geschreven.",
|
||||
"previewKeyMissing": "Deze back-up kan niet op deze server worden teruggezet: er is versleutelingssleutel {keys} voor nodig. Voeg de sleutel eerst weer toe aan ENCRYPTION_KEYS en herstart.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Symptoomregistratie gekoppeld aan een ziekte die niet in het bestand staat (behouden zonder de koppeling)",
|
||||
"restoreSkippedCycleCiphertext": "Cyclusnotitie of -label versleuteld met een sleutel die deze server niet heeft (dag of symptoom behouden zonder)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "De nieuwste dump in de bucket is van {when}.",
|
||||
"offhostNeverUploaded": "Er is nog geen databasedump in de bucket aangekomen."
|
||||
},
|
||||
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug."
|
||||
"restorePreWipeLimits": "Deze kopie is gemaakt voordat de gegevens van het account werden verwijderd, en terugzetten maakt dat verwijderen ongedaan voor zover een kopie dat kan: wat het account sindsdien heeft toegevoegd, wordt vervangen, en koppelingen, delen, verzorgerstoegang en deellinks komen niet terug.",
|
||||
"target": {
|
||||
"title": "In een ander account terugzetten",
|
||||
"description": "{file} hoort bij een account dat niet meer op deze server staat. Kies het account waar de records naartoe gaan. Terugzetten vervangt de records van een account, dus alleen een account zonder records kan ze opnemen. Het bestand wordt nog een keer geüpload.",
|
||||
"choiceLabel": "Waar de records naartoe gaan",
|
||||
"newAccount": "Nieuw account",
|
||||
"existingAccount": "Bestaand account",
|
||||
"usernameLabel": "Gebruikersnaam voor het nieuwe account",
|
||||
"usernameInvalid": "3 tot 30 letters, cijfers, _ of -, niet beginnend met ‘managed-’.",
|
||||
"newAccountHint": "Het account wordt zonder wachtwoord en zonder e-mail aangemaakt. Stel het eerste wachtwoord in met Wachtwoord resetten onder Gebruikers voordat je het overdraagt.",
|
||||
"accountLabel": "Account",
|
||||
"accountPlaceholder": "Kies een account",
|
||||
"checking": "Account wordt gecontroleerd…",
|
||||
"eligible": "Dit account heeft nog geen records en kan de kopie opnemen.",
|
||||
"confirmLabel": "Typ {username} om te bevestigen",
|
||||
"submit": "Naar dit account uploaden",
|
||||
"submitting": "Uploaden…",
|
||||
"success": "Back-up opgeslagen voor {username} ({count} records). Zet hem terug vanuit zijn rij.",
|
||||
"failureHoldsRecords": "Dit account heeft al records. Terugzetten vervangt records in plaats van ze aan te vullen, dus kies een account zonder records of maak een nieuw account aan.",
|
||||
"failureManagedProfile": "Dit account is een beheerd profiel. De verzorgers beheren het, dus er kan geen kopie in.",
|
||||
"failurePendingDeletion": "Dit account wordt verwijderd. Maak de verwijdering eerst ongedaan of kies een ander account.",
|
||||
"failureAdminAccount": "Dit is een beheerdersaccount. De records van iemand anders kunnen er niet in, dus kies een gewoon account of maak een nieuw account aan.",
|
||||
"failureNotFound": "Dit account bestaat niet meer.",
|
||||
"failureUnconfirmed": "Typ de gebruikersnaam van het account precies over om te bevestigen.",
|
||||
"failureUsernameTaken": "Deze gebruikersnaam is al in gebruik.",
|
||||
"failureOwnerExists": "Het account waar dit bestand bij hoort, staat op deze server, en het bestand wordt in dat account teruggezet. Upload het opnieuw zonder een account te kiezen."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gevarenzone",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Begin",
|
||||
"end": "Einde",
|
||||
"seriesCaption": "Gemiddelde waarden per periode",
|
||||
"period": "Periode"
|
||||
"period": "Periode",
|
||||
"labsCaption": "Labwaarden per dag",
|
||||
"marker": "Marker",
|
||||
"date": "Datum",
|
||||
"value": "Waarde"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Geen registraties in deze periode",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 labwaarde",
|
||||
"labValuesFew": "{count} labwaarden",
|
||||
"labValuesOther": "{count} labwaarden"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Labwaarden",
|
||||
"otherPanel": "Overig",
|
||||
"full": "Maximaal {max} labwaarden tegelijk. Verwijder er een om een andere toe te voegen.",
|
||||
"above": "boven het bereik",
|
||||
"below": "onder het bereik",
|
||||
"reference": "referentie {range}",
|
||||
"report": "Verslag: {title}",
|
||||
"reportUntitled": "Verslag openen",
|
||||
"excludedOne": "{name}: {count} waarde in een andere eenheid wordt niet getoond",
|
||||
"excludedFew": "{name}: {count} waarden in een andere eenheid worden niet getoond",
|
||||
"excludedOther": "{name}: {count} waarden in een andere eenheid worden niet getoond"
|
||||
},
|
||||
"legendLabValue": "Labwaarde, zoals gemeten",
|
||||
"legendLabOutside": "Buiten het referentiebereik",
|
||||
"legendLabRange": "Referentiebereik"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Levensgebeurtenis",
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Pobieranie nie powiodło się",
|
||||
"downloadStarted": "Rozpoczęto pobieranie",
|
||||
"uploadTitle": "Prześlij kopię zapasową",
|
||||
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Konto, do którego należy, musi istnieć na tym serwerze. Przywracanie to osobny krok.",
|
||||
"uploadDescription": "Sprawdza kopię konta i zapisuje ją obok kopii tego serwera. Kopia, której konta nie ma już na tym serwerze, może trafić do innego konta. Przywracanie to osobny krok.",
|
||||
"uploadButton": "Prześlij plik",
|
||||
"uploadHelp": "Kopia zapasowa HealthLog w formacie .json lub .json.gz. Plik jest kompresowany przed wysłaniem; po kompresji może mieć do 512 MB.",
|
||||
"uploadSuccess": "Przesłano kopię zapasową ({count} rekordów)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.",
|
||||
"restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.",
|
||||
"restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.",
|
||||
"restoreFailureRowsElsewhere": "Rekordy z tego pliku są już na innym koncie na tym serwerze: ten sam plik został tam wcześniej przywrócony. Plik można przywrócić tylko na jedno konto. Nic nie zostało zapisane.",
|
||||
"previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)",
|
||||
"restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "Najnowszy zrzut w buckecie pochodzi z {when}.",
|
||||
"offhostNeverUploaded": "Do bucketu nie trafił jeszcze żaden zrzut bazy danych."
|
||||
},
|
||||
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają."
|
||||
"restorePreWipeLimits": "Tę kopię wykonano przed usunięciem danych konta, a jej przywrócenie cofa to usunięcie na tyle, na ile pozwala kopia: to, co konto dodało od tego czasu, zostaje zastąpione, a połączenia integracji, udostępnienia, dostęp opiekunów i linki udostępniania nie wracają.",
|
||||
"target": {
|
||||
"title": "Przywróć do innego konta",
|
||||
"description": "{file} należy do konta, którego nie ma już na tym serwerze. Wybierz konto, do którego trafią jego rekordy. Przywracanie zastępuje rekordy konta, więc przyjąć je może tylko konto bez rekordów. Plik zostanie przesłany jeszcze raz.",
|
||||
"choiceLabel": "Dokąd trafią rekordy",
|
||||
"newAccount": "Nowe konto",
|
||||
"existingAccount": "Istniejące konto",
|
||||
"usernameLabel": "Nazwa użytkownika nowego konta",
|
||||
"usernameInvalid": "Od 3 do 30 liter, cyfr, _ lub -, bez „managed-” na początku.",
|
||||
"newAccountHint": "Konto zostanie utworzone bez hasła i bez adresu e-mail. Zanim je przekażesz, ustaw jego pierwsze hasło przyciskiem Zresetuj hasło w sekcji Użytkownicy.",
|
||||
"accountLabel": "Konto",
|
||||
"accountPlaceholder": "Wybierz konto",
|
||||
"checking": "Sprawdzanie konta…",
|
||||
"eligible": "To konto nie ma jeszcze rekordów i może przyjąć kopię.",
|
||||
"confirmLabel": "Wpisz {username}, aby potwierdzić",
|
||||
"submit": "Prześlij do tego konta",
|
||||
"submitting": "Przesyłanie…",
|
||||
"success": "Kopia zapisana dla {username} ({count} rekordów). Przywróć ją z jej wiersza.",
|
||||
"failureHoldsRecords": "To konto ma już rekordy. Przywracanie zastępuje rekordy zamiast je uzupełniać, więc wybierz konto bez rekordów albo utwórz nowe.",
|
||||
"failureManagedProfile": "To konto jest profilem zarządzanym. Prowadzą je osoby opiekujące się nim, więc kopia nie może do niego trafić.",
|
||||
"failurePendingDeletion": "To konto czeka na usunięcie. Najpierw cofnij usunięcie albo wybierz inne konto.",
|
||||
"failureAdminAccount": "To jest konto administratora. Nie można w nim umieścić rekordów innej osoby, więc wybierz zwykłe konto albo utwórz nowe.",
|
||||
"failureNotFound": "To konto już nie istnieje.",
|
||||
"failureUnconfirmed": "Wpisz dokładnie nazwę użytkownika konta, aby potwierdzić.",
|
||||
"failureUsernameTaken": "Ta nazwa użytkownika jest już zajęta.",
|
||||
"failureOwnerExists": "Konto, do którego należy ten plik, jest na tym serwerze, a plik przywraca się do tego konta. Prześlij go ponownie bez wybierania konta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Początek",
|
||||
"end": "Koniec",
|
||||
"seriesCaption": "Średnie według okresu",
|
||||
"period": "Okres"
|
||||
"period": "Okres",
|
||||
"labsCaption": "Wyniki badań według dni",
|
||||
"marker": "Parametr",
|
||||
"date": "Data",
|
||||
"value": "Wartość"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Brak wpisów w tym okresie",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 wynik badania",
|
||||
"labValuesFew": "{count} wyniki badań",
|
||||
"labValuesOther": "{count} wyników badań"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Wyniki badań",
|
||||
"otherPanel": "Inne",
|
||||
"full": "Naraz można pokazać do {max} wyników badań. Usuń jeden, aby dodać inny.",
|
||||
"above": "powyżej zakresu",
|
||||
"below": "poniżej zakresu",
|
||||
"reference": "zakres {range}",
|
||||
"report": "Dokument: {title}",
|
||||
"reportUntitled": "Otwórz dokument",
|
||||
"excludedOne": "{name}: {count} wartość w innej jednostce nie jest pokazana",
|
||||
"excludedFew": "{name}: {count} wartości w innej jednostce nie są pokazane",
|
||||
"excludedOther": "{name}: {count} wartości w innej jednostce nie jest pokazanych"
|
||||
},
|
||||
"legendLabValue": "Wynik badania, tak jak zmierzono",
|
||||
"legendLabOutside": "Poza zakresem referencyjnym",
|
||||
"legendLabRange": "Zakres referencyjny"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Wydarzenie życiowe",
|
||||
|
||||
+51
-4
@@ -8217,7 +8217,7 @@
|
||||
"downloadFailed": "Falha no download",
|
||||
"downloadStarted": "Download iniciado",
|
||||
"uploadTitle": "Enviar backup",
|
||||
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. A conta a que ela pertence precisa existir neste servidor. Restaurar é uma etapa separada.",
|
||||
"uploadDescription": "Verifica uma cópia de conta e a guarda junto às cópias deste servidor. Uma cópia cuja conta não está mais neste servidor pode ir para outra conta. Restaurar é uma etapa separada.",
|
||||
"uploadButton": "Enviar arquivo",
|
||||
"uploadHelp": "Um backup do HealthLog como .json ou .json.gz. O arquivo é comprimido antes do envio; comprimido, pode ter até 512 MB.",
|
||||
"uploadSuccess": "Backup enviado ({count} registros)",
|
||||
@@ -8345,6 +8345,7 @@
|
||||
"restoreFailureUnexpected": "A restauração parou em um erro inesperado e foi revertida. O log do servidor nomeia o erro.",
|
||||
"restoreFailureKeyMissing": "O backup precisa da chave de criptografia {keys}, que este servidor não consegue usar para abri-lo. Adicione a chave de volta a ENCRYPTION_KEYS, reinicie e inicie a restauração novamente.",
|
||||
"restoreFailureForeignReference": "O arquivo aponta para registros que pertencem a outra conta. Nada foi gravado.",
|
||||
"restoreFailureRowsElsewhere": "Os registros deste arquivo já estão em outra conta deste servidor: o mesmo arquivo já foi restaurado nela. Um arquivo só pode ser restaurado em uma conta. Nada foi gravado.",
|
||||
"previewKeyMissing": "Este backup não pode ser restaurado neste servidor: ele precisa da chave de criptografia {keys}. Adicione a chave de volta a ENCRYPTION_KEYS e reinicie primeiro.",
|
||||
"restoreSkippedSymptomEpisodeReference": "Entrada de sintoma vinculada a uma doença que o arquivo não contém (mantida sem o link)",
|
||||
"restoreSkippedCycleCiphertext": "Nota ou rótulo do ciclo selado com uma chave que este servidor não possui (dia ou sintoma mantido sem ele)",
|
||||
@@ -8376,7 +8377,33 @@
|
||||
"offhostLastUpload": "O dump mais recente no bucket é de {when}.",
|
||||
"offhostNeverUploaded": "Nenhum dump do banco de dados chegou ao bucket ainda."
|
||||
},
|
||||
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam."
|
||||
"restorePreWipeLimits": "Esta cópia foi feita antes de os dados da conta serem excluídos, e restaurá-la desfaz essa exclusão até onde uma cópia consegue: o que a conta adicionou desde então é substituído, e conexões de integração, compartilhamentos, acesso de guardiões e links de compartilhamento não voltam.",
|
||||
"target": {
|
||||
"title": "Restaurar em outra conta",
|
||||
"description": "{file} pertence a uma conta que não está mais neste servidor. Escolha a conta para onde vão os registros. Uma restauração substitui os registros de uma conta, então só uma conta sem registros pode recebê-los. O arquivo é enviado mais uma vez.",
|
||||
"choiceLabel": "Para onde vão os registros",
|
||||
"newAccount": "Conta nova",
|
||||
"existingAccount": "Conta existente",
|
||||
"usernameLabel": "Nome de usuário da conta nova",
|
||||
"usernameInvalid": "De 3 a 30 letras, números, _ ou -, sem começar com “managed-”.",
|
||||
"newAccountHint": "A conta é criada sem senha e sem e-mail. Antes de entregá-la, defina a primeira senha com Redefinir senha em Usuários.",
|
||||
"accountLabel": "Conta",
|
||||
"accountPlaceholder": "Escolha uma conta",
|
||||
"checking": "Verificando a conta…",
|
||||
"eligible": "Esta conta ainda não tem registros e pode receber a cópia.",
|
||||
"confirmLabel": "Digite {username} para confirmar",
|
||||
"submit": "Enviar para esta conta",
|
||||
"submitting": "Enviando…",
|
||||
"success": "Backup guardado para {username} ({count} registros). Restaure-o pela linha dele.",
|
||||
"failureHoldsRecords": "Esta conta já tem registros. Uma restauração substitui os registros em vez de somá-los, então escolha uma conta sem registros ou crie uma nova.",
|
||||
"failureManagedProfile": "Esta conta é um perfil gerenciado. Os guardiões cuidam dela, então uma cópia não pode ir para ela.",
|
||||
"failurePendingDeletion": "Esta conta está aguardando exclusão. Desfaça a exclusão primeiro ou escolha outra conta.",
|
||||
"failureAdminAccount": "Esta é uma conta de administrador. Os registros de outra pessoa não podem ir para ela, então escolha uma conta comum ou crie uma nova.",
|
||||
"failureNotFound": "Esta conta não existe mais.",
|
||||
"failureUnconfirmed": "Digite exatamente o nome de usuário da conta para confirmar.",
|
||||
"failureUsernameTaken": "Este nome de usuário já está em uso.",
|
||||
"failureOwnerExists": "A conta a que este arquivo pertence está neste servidor, e o arquivo é restaurado nessa conta. Envie-o de novo sem escolher uma conta."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de perigo",
|
||||
@@ -13655,7 +13682,11 @@
|
||||
"start": "Início",
|
||||
"end": "Fim",
|
||||
"seriesCaption": "Médias de valores por período",
|
||||
"period": "Período"
|
||||
"period": "Período",
|
||||
"labsCaption": "Valores de exames por dia",
|
||||
"marker": "Marcador",
|
||||
"date": "Data",
|
||||
"value": "Valor"
|
||||
},
|
||||
"selection": {
|
||||
"empty": "Nenhuma entrada neste período",
|
||||
@@ -13700,7 +13731,23 @@
|
||||
"labValuesOne": "1 valor laboratorial",
|
||||
"labValuesFew": "{count} valores laboratoriais",
|
||||
"labValuesOther": "{count} valores laboratoriais"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"menuTitle": "Valores de exames",
|
||||
"otherPanel": "Outros",
|
||||
"full": "Até {max} valores de exames ao mesmo tempo. Remova um para adicionar outro.",
|
||||
"above": "acima da faixa",
|
||||
"below": "abaixo da faixa",
|
||||
"reference": "referência {range}",
|
||||
"report": "Laudo: {title}",
|
||||
"reportUntitled": "Abrir o laudo",
|
||||
"excludedOne": "{name}: {count} valor em outra unidade não aparece no gráfico",
|
||||
"excludedFew": "{name}: {count} valores em outra unidade não aparecem no gráfico",
|
||||
"excludedOther": "{name}: {count} valores em outra unidade não aparecem no gráfico"
|
||||
},
|
||||
"legendLabValue": "Valor de exame, como foi medido",
|
||||
"legendLabOutside": "Fora da faixa de referência",
|
||||
"legendLabRange": "Faixa de referência"
|
||||
},
|
||||
"lifeEvents": {
|
||||
"title": "Evento de vida",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.46.2",
|
||||
"version": "1.46.3",
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -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.46.2";
|
||||
/* @sw-version-fallback */ "v1.46.3";
|
||||
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/*`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94692,
|
||||
"total": 94362,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -18,8 +18,8 @@
|
||||
"components/medications/wizard/wizard-payload.ts": 1414,
|
||||
"components/mood/mood-list.tsx": 1408,
|
||||
"components/records/life-circumstances-manager.tsx": 1080,
|
||||
"components/timeline/timeline-chart.tsx": 1174,
|
||||
"components/timeline/timeline-geometry.ts": 1395,
|
||||
"components/timeline/timeline-chart.tsx": 1093,
|
||||
"components/timeline/timeline-geometry.ts": 1288,
|
||||
"lib/ai/anthropic-client.ts": 804,
|
||||
"lib/ai/coach/about-me.ts": 1012,
|
||||
"lib/ai/coach/coach-prose-grounding.ts": 1079,
|
||||
@@ -28,9 +28,9 @@
|
||||
"lib/ai/coach/snapshot.ts": 1346,
|
||||
"lib/ai/coach/stream-events.ts": 817,
|
||||
"lib/ai/coach/types.ts": 1011,
|
||||
"lib/ai/codex-client.ts": 1045,
|
||||
"lib/ai/codex-client.ts": 907,
|
||||
"lib/ai/prompts/insight-generator.ts": 1110,
|
||||
"lib/ai/provider.ts": 1632,
|
||||
"lib/ai/provider.ts": 1628,
|
||||
"lib/analytics/score/reader.ts": 998,
|
||||
"lib/analytics/sleep-night.ts": 1345,
|
||||
"lib/analytics/summaries-slice.ts": 1178,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 346,
|
||||
"total": 112219,
|
||||
"count": 345,
|
||||
"total": 111929,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 378,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
@@ -19,7 +19,7 @@
|
||||
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
|
||||
"app/api/environment/route.ts :: apiHandler(arg)": 201,
|
||||
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
|
||||
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
|
||||
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
|
||||
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
@@ -62,7 +62,7 @@
|
||||
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
|
||||
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
|
||||
"components/admin/reminders-section.tsx :: RemindersSection": 309,
|
||||
"components/admin/user-management-section.tsx :: UserManagementSection": 626,
|
||||
"components/admin/user-management-section.tsx :: UserManagementSection": 622,
|
||||
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
|
||||
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
|
||||
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
|
||||
@@ -232,10 +232,9 @@
|
||||
"components/targets/range-bar.tsx :: RangeBar": 220,
|
||||
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474,
|
||||
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
|
||||
"components/timeline/series-point-tips.tsx :: SeriesPointTips": 212,
|
||||
"components/timeline/timeline-chart.tsx :: ChartBody": 243,
|
||||
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 274,
|
||||
"components/timeline/timeline-view.tsx :: TimelineView": 501,
|
||||
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
|
||||
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
|
||||
"components/timeline/timeline-view.tsx :: TimelineView": 486,
|
||||
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239,
|
||||
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
|
||||
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
|
||||
@@ -285,7 +284,7 @@
|
||||
"lib/gamification/achievements-result.ts :: buildAchievementsResult": 586,
|
||||
"lib/google-health/data-points.ts :: forEachDataPointPage": 288,
|
||||
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
|
||||
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453,
|
||||
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452,
|
||||
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv": 309,
|
||||
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
|
||||
@@ -348,6 +347,6 @@
|
||||
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
|
||||
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
|
||||
"lib/withings/sync.ts :: syncUserMeasurements": 337,
|
||||
"proxy.ts :: proxy": 499
|
||||
"proxy.ts :: proxy": 498
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2896,
|
||||
"total": 9228,
|
||||
"count": 2895,
|
||||
"total": 9226,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -1741,8 +1741,7 @@
|
||||
"lib/ai/coach/turn/types.ts": 17,
|
||||
"lib/ai/coach/types.ts": 58,
|
||||
"lib/ai/coach/visits-snapshot.ts": 1,
|
||||
"lib/ai/codex-client.ts": 18,
|
||||
"lib/ai/codex-slug-cache.ts": 1,
|
||||
"lib/ai/codex-client.ts": 17,
|
||||
"lib/ai/consent-guard.ts": 1,
|
||||
"lib/ai/feedback-attribution.ts": 2,
|
||||
"lib/ai/insight-interpretation.ts": 1,
|
||||
|
||||
@@ -225,6 +225,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
// newer schema) an operator has to be able to look up the same way. See
|
||||
// `src/lib/openapi/routes/admin-backups.ts`.
|
||||
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/backups/targets/{userId}": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/central-codex": {
|
||||
kind: "adminConsole",
|
||||
methods: ["DELETE", "GET"],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* GET /api/admin/backups/targets/{userId} — whether the copy of an erased
|
||||
* account may be restored into this account.
|
||||
*
|
||||
* The backups console asks before it sends a file a second time with that
|
||||
* account as its target, so a refusal costs no upload. The upload decides
|
||||
* again on its own (`checkRestoreTarget` in `restore-target.ts`); this answer
|
||||
* is advice, not a reservation. It says no for a managed profile, an account
|
||||
* pending deletion, and an account that already holds records, naming the
|
||||
* models that hold them. Counts and record content are not read.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRestoreTarget } from "@/lib/export/restore-target";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const paramsSchema = z.object({ userId: z.string().min(1).max(64) }).strict();
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ userId: string }> },
|
||||
) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.backups.target.check" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`admin-backups-target:${admin.id}`,
|
||||
30,
|
||||
60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
throw new HttpError(429, "Too many account checks");
|
||||
}
|
||||
|
||||
const parsed = paramsSchema.safeParse(await params);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error);
|
||||
|
||||
const verdict = await checkRestoreTarget(prisma, parsed.data.userId);
|
||||
if (!verdict.ok && verdict.reason === "not_found") {
|
||||
throw new HttpError(404, "User not found");
|
||||
}
|
||||
const target = verdict.target!;
|
||||
return apiSuccess({
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
eligible: verdict.ok,
|
||||
reason: verdict.ok ? null : verdict.reason,
|
||||
models: verdict.ok ? [] : verdict.models,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -10,8 +10,10 @@
|
||||
* 3. Parses and validates the JSON against the canonical schema.
|
||||
* 4. Rejects files written by a future schema version (incompatible).
|
||||
* 5. Encrypts the file as it arrives and inserts a new `DataBackup` row
|
||||
* of type `MANUAL_UPLOAD_<unix-ms>` so multiple uploads coexist with
|
||||
* the rolling `WEEKLY_AUTO` snapshot AND with each other.
|
||||
* of type `MANUAL_UPLOAD_<unix-ms>` (`RETARGETED_UPLOAD_<unix-ms>` for
|
||||
* a copy put into a chosen account, `backup-upload-type.ts`) so
|
||||
* multiple uploads coexist with the rolling `WEEKLY_AUTO` snapshot AND
|
||||
* with each other.
|
||||
*
|
||||
* The file is read as a stream and never held whole (#1031): an export of an
|
||||
* account with 1.25 million measurements is a few hundred megabytes of JSON,
|
||||
@@ -20,6 +22,17 @@
|
||||
* checked as it goes and stored in the same pass; a file that fails any check
|
||||
* rolls the store back, so nothing of it remains.
|
||||
*
|
||||
* The file names the account it belongs to, and that account has to exist
|
||||
* here. When it does not (the account was erased, and the operator still has
|
||||
* its disaster-recovery file), the upload answers 422 with
|
||||
* `backup.owner.missing`, and the operator can send the file again with a
|
||||
* target in the query (`backup-upload-target.ts`): an existing account that
|
||||
* holds no records yet, confirmed by typing its username, or a new account
|
||||
* created for the copy. The file's owner is then rewritten to that account as
|
||||
* the bytes come in (`backup-owner-rewrite.ts`), so the copy stored is that
|
||||
* account's copy, and the restore that follows is the ordinary one. The new
|
||||
* account has no password; an admin sets its first one in Admin, Users.
|
||||
*
|
||||
* Crucially, this route does NOT execute a restore. It only stores the
|
||||
* file. The separate `POST /api/admin/backups/[id]/restore` endpoint
|
||||
* (criterion 3) actually replaces user data — keeping the two phases
|
||||
@@ -32,6 +45,7 @@ import { Readable } from "node:stream";
|
||||
|
||||
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -40,9 +54,19 @@ import {
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { BackupJsonError } from "@/lib/export/backup-json-scan";
|
||||
import {
|
||||
BackupOwnerRewriteError,
|
||||
rewriteBackupOwner,
|
||||
type BackupOwnerRewrite,
|
||||
} from "@/lib/export/backup-owner-rewrite";
|
||||
import {
|
||||
BackupUploadDecodeError,
|
||||
decodeBackupUpload,
|
||||
@@ -57,7 +81,13 @@ import {
|
||||
BACKUP_UPLOAD_TOO_LARGE_CODE,
|
||||
BackupBlobTooLargeError,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import {
|
||||
checkRestoreTarget,
|
||||
RESTORE_TARGET_REFUSALS,
|
||||
type RestoreTargetVerdict,
|
||||
} from "@/lib/export/restore-target";
|
||||
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
||||
import { uploadedBackupType } from "@/lib/export/backup-upload-type";
|
||||
import {
|
||||
readStreamedBackup,
|
||||
StreamedBackupInvalidError,
|
||||
@@ -66,6 +96,14 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
BACKUP_OWNER_EXISTS_CODE,
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
BACKUP_TARGET_UNCONFIRMED_CODE,
|
||||
BACKUP_TARGET_USERNAME_TAKEN_CODE,
|
||||
parseBackupUploadTarget,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
summarizeBackup,
|
||||
@@ -111,6 +149,21 @@ class UploadRefused extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer and audit details for a target the copy may not go into. */
|
||||
function targetRefused(
|
||||
verdict: Extract<RestoreTargetVerdict, { ok: false }>,
|
||||
userId: string,
|
||||
): UploadRefused {
|
||||
const refusal = RESTORE_TARGET_REFUSALS[verdict.reason];
|
||||
const models = verdict.models.length > 0 ? { models: verdict.models } : {};
|
||||
return new UploadRefused(
|
||||
refusal.status,
|
||||
refusal.message,
|
||||
{ reason: `target_${verdict.reason}`, targetUserId: userId, ...models },
|
||||
{ errorCode: refusal.code, ...models },
|
||||
);
|
||||
}
|
||||
|
||||
/** The file's bytes, gunzipped and counted on both ends. */
|
||||
async function* decodedBytes(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
@@ -129,6 +182,242 @@ async function* decodedBytes(
|
||||
}
|
||||
}
|
||||
|
||||
/** A file's read failure as the refusal the operator sees. */
|
||||
function schemaRefusal(issues: Array<{ path: string; message: string }>) {
|
||||
return new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the file through the one streamed reader every backup reader shares
|
||||
* (`streamed-backup.ts`): the bulk sections an element at a time and the
|
||||
* documents without their stored bytes, so a document vault of hundreds of
|
||||
* megabytes is never in memory at once (#1239). It checks each measurement
|
||||
* and the canonical v2 id rule as it goes, and gathers the key ids the file's
|
||||
* inner ciphertext needs.
|
||||
*/
|
||||
async function readUploadedFile(
|
||||
source: () => AsyncIterable<Uint8Array>,
|
||||
): Promise<StreamedBackup> {
|
||||
try {
|
||||
return await readStreamedBackup(source);
|
||||
} catch (err) {
|
||||
if (err instanceof BackupJsonError) {
|
||||
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
|
||||
reason: "invalid_json",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof BackupOwnerRewriteError) {
|
||||
throw schemaRefusal([{ path: "userId", message: err.message }]);
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
throw schemaRefusal([{ path: err.path, message: err.message }]);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The file against the backup schema, and its version against this server. */
|
||||
async function parseUploadedPayload(raw: unknown): Promise<BackupPayload> {
|
||||
// Loaded on first use, not at import (see `backup-summary.ts`).
|
||||
const { backupPayloadSchema } = await import("@/lib/validations/backup");
|
||||
let payload: BackupPayload;
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(raw);
|
||||
} catch (err) {
|
||||
throw schemaRefusal(
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
{
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
type ChosenTarget = { id: string; username: string; created: boolean };
|
||||
|
||||
const usernameTaken = () =>
|
||||
new UploadRefused(
|
||||
409,
|
||||
"That username is already taken.",
|
||||
{ reason: "target_username_taken" },
|
||||
{ errorCode: BACKUP_TARGET_USERNAME_TAKEN_CODE },
|
||||
);
|
||||
|
||||
/**
|
||||
* The target the query names, checked before a byte of the file is read, so
|
||||
* a target that will be refused costs no upload. An existing account is
|
||||
* checked whole and confirmed by its username; a new one only for its
|
||||
* username, since it is created once the request is otherwise admissible.
|
||||
*/
|
||||
async function checkTargetBeforeRead(
|
||||
choice: BackupUploadTarget | null,
|
||||
): Promise<ChosenTarget | null> {
|
||||
if (choice?.into === "existing") {
|
||||
const verdict = await checkRestoreTarget(prisma, choice.userId);
|
||||
if (!verdict.ok) throw targetRefused(verdict, choice.userId);
|
||||
// The restore writes into this account, so the operator names it.
|
||||
if (choice.confirm.trim() !== verdict.target.username) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Type the chosen account's username to confirm. Nothing was stored.",
|
||||
{ reason: "target_unconfirmed", targetUserId: choice.userId },
|
||||
{ errorCode: BACKUP_TARGET_UNCONFIRMED_CODE },
|
||||
);
|
||||
}
|
||||
return { ...verdict.target, created: false };
|
||||
}
|
||||
if (choice?.into === "new") {
|
||||
const taken = await prisma.user.findUnique({
|
||||
where: { username: choice.username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) throw usernameTaken();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Who made an account for a copy, for the trail. */
|
||||
interface UploadActor {
|
||||
adminId: string;
|
||||
ipAddress: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account made for a copy. Created before the file is read, because the
|
||||
* file is rewritten to its id on the way in, and deleted again when nothing
|
||||
* is stored. No password, no email: nobody can sign into it until an admin
|
||||
* sets its first password (Admin, Users, Reset password). Its audit row
|
||||
* commits with it, so an account this route made always has a trace, even
|
||||
* when the upload fails and removing it again fails too.
|
||||
*/
|
||||
async function createTargetAccount(
|
||||
username: string,
|
||||
actor: UploadActor,
|
||||
): Promise<ChosenTarget> {
|
||||
try {
|
||||
const account = await prisma.$transaction(async (tx) => {
|
||||
const row = await tx.user.create({
|
||||
data: { username, role: "USER" },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
await auditLog("admin.user.create", {
|
||||
userId: actor.adminId,
|
||||
ipAddress: actor.ipAddress,
|
||||
client: tx,
|
||||
details: {
|
||||
targetUserId: row.id,
|
||||
username: row.username,
|
||||
reason: "backup_restore",
|
||||
outcome: "created",
|
||||
},
|
||||
});
|
||||
return row;
|
||||
});
|
||||
return { ...account, created: true };
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
throw usernameTaken();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose copy a file sent with a target becomes, once it has been read. The
|
||||
* file now names the chosen account; the account it named before has to be
|
||||
* gone, because the copy of an account that is still here restores into that
|
||||
* account and never into another one. An existing target is checked again:
|
||||
* the file took a while to read, and the account may have changed meanwhile.
|
||||
*/
|
||||
async function ownerOfRetargetedFile(
|
||||
chosen: ChosenTarget,
|
||||
rewrite: BackupOwnerRewrite | null,
|
||||
): Promise<{ id: string; username: string }> {
|
||||
const declared = rewrite?.declaredOwner() ?? null;
|
||||
const declaredOwner = declared
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: declared },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!declared || declaredOwner) {
|
||||
throw new UploadRefused(
|
||||
409,
|
||||
"This file belongs to an account on this server and restores into that account. Upload it without choosing an account.",
|
||||
{ reason: "owner_exists", ownerId: declared },
|
||||
{ errorCode: BACKUP_OWNER_EXISTS_CODE },
|
||||
);
|
||||
}
|
||||
if (!chosen.created) {
|
||||
const again = await checkRestoreTarget(prisma, chosen.id);
|
||||
if (!again.ok) throw targetRefused(again, chosen.id);
|
||||
}
|
||||
return { id: chosen.id, username: chosen.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account an upload created for its copy, when nothing was stored.
|
||||
* It holds nothing and nobody can have signed into it (no password, no email),
|
||||
* so the row is all there is. Either way the trail says what became of it: a
|
||||
* removal that failed leaves an account to delete by hand
|
||||
* (`docs/ops/backup-restore.md`). A failure is noted, never thrown over the
|
||||
* refusal the operator needs to see.
|
||||
*/
|
||||
async function discardCreatedAccount(
|
||||
account: ChosenTarget,
|
||||
actor: UploadActor,
|
||||
): Promise<void> {
|
||||
let outcome: "deleted" | "failed" = "deleted";
|
||||
try {
|
||||
await prisma.user.delete({ where: { id: account.id } });
|
||||
} catch (err) {
|
||||
outcome = "failed";
|
||||
annotate({
|
||||
meta: {
|
||||
backup_upload_account_discard_failed: true,
|
||||
backup_upload_account_discard_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
await auditLog("admin.user.delete", {
|
||||
userId: actor.adminId,
|
||||
ipAddress: actor.ipAddress,
|
||||
details: {
|
||||
targetUserId: account.id,
|
||||
username: account.username,
|
||||
reason: "backup_upload_refused",
|
||||
outcome,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
annotate({ meta: { backup_upload_account_discard_unaudited: true } });
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
@@ -136,6 +425,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
annotate({ action: { name: "admin.backups.upload" } });
|
||||
const ipAddress = getClientIp(request);
|
||||
const actor: UploadActor = { adminId: admin.id, ipAddress };
|
||||
|
||||
// Rate-limit identical to the manual-run endpoint. Three is plenty for a
|
||||
// deliberate restore-prep workflow.
|
||||
@@ -154,6 +444,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress,
|
||||
details: { reason, ...extra },
|
||||
});
|
||||
const refuse = async (err: UploadRefused) => {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
};
|
||||
|
||||
// The account the copy goes into when the file's own account is gone.
|
||||
const parsedTarget = parseBackupUploadTarget(
|
||||
new URL(request.url).searchParams,
|
||||
);
|
||||
if (!parsedTarget.success) {
|
||||
await denied("target_invalid");
|
||||
return returnAllZodIssues(parsedTarget.error);
|
||||
}
|
||||
const choice = parsedTarget.data;
|
||||
let target: ChosenTarget | null;
|
||||
try {
|
||||
target = await checkTargetBeforeRead(choice);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
throw err;
|
||||
}
|
||||
annotate({ meta: { backup_upload_into: choice?.into ?? "owner" } });
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const multipart = contentType.startsWith("multipart/form-data");
|
||||
@@ -214,10 +526,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// `MANUAL_UPLOAD_<unix-ms>` keeps the (userId, type) unique-constraint
|
||||
// intact so multiple uploads can coexist for the same user without
|
||||
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
|
||||
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
|
||||
// The millisecond suffix keeps uploads and WEEKLY_AUTO apart under the
|
||||
// (userId, type) unique constraint; a copy put into a chosen account is
|
||||
// marked as such, for its restore (`backup-upload-type.ts`).
|
||||
const uploadType = uploadedBackupType(Boolean(choice), Date.now());
|
||||
let payload: BackupPayload | undefined;
|
||||
let measurementCount = 0;
|
||||
let owner: { id: string; username: string } | null = null;
|
||||
@@ -226,6 +538,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// answers with and the key uses it already holds (`backup-preview.ts`).
|
||||
let keys: BackupKeyIdCollector | undefined;
|
||||
let created: { id: string };
|
||||
let rewrite: BackupOwnerRewrite | null = null;
|
||||
if (choice?.into === "new") {
|
||||
try {
|
||||
target = await createTargetAccount(choice.username, actor);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const chosen = target;
|
||||
try {
|
||||
created = await storeBackupBlob(
|
||||
prisma,
|
||||
@@ -247,72 +569,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
async (write) => {
|
||||
// One pass: every chunk is stored as it is checked, and a check that
|
||||
// fails throws, which rolls the store back.
|
||||
const decoded = decodedBytes(source, limit);
|
||||
if (chosen) rewrite = rewriteBackupOwner(decoded, chosen.id);
|
||||
async function* storedAsRead() {
|
||||
for await (const chunk of decodedBytes(source, limit)) {
|
||||
for await (const chunk of rewrite ? rewrite.bytes : decoded) {
|
||||
await write(Buffer.from(chunk));
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
// Read through the one streamed reader every backup reader shares
|
||||
// (`streamed-backup.ts`): the bulk sections an element at a time and
|
||||
// the documents without their stored bytes, so a document vault of
|
||||
// hundreds of megabytes is never in memory at once (#1239). It checks
|
||||
// each measurement and the canonical v2 id rule as it goes, and
|
||||
// gathers the key ids the file's inner ciphertext needs.
|
||||
let streamed: StreamedBackup;
|
||||
try {
|
||||
streamed = await readStreamedBackup(storedAsRead);
|
||||
} catch (err) {
|
||||
if (err instanceof BackupJsonError) {
|
||||
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
|
||||
reason: "invalid_json",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
const issues = [{ path: err.path, message: err.message }];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const streamed = await readUploadedFile(storedAsRead);
|
||||
measurementCount = streamed.measurementCount;
|
||||
|
||||
// Loaded on first use, not at import (see `backup-summary.ts`).
|
||||
const { backupPayloadSchema } =
|
||||
await import("@/lib/validations/backup");
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(streamed.raw);
|
||||
} catch (err) {
|
||||
const issues =
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
{
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
payload = await parseUploadedPayload(streamed.raw);
|
||||
|
||||
// A file whose inner ciphertext needs a key this host does not have
|
||||
// can be stored but never restored: the restore would refuse it, or
|
||||
@@ -335,6 +602,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (chosen) {
|
||||
owner = await ownerOfRetargetedFile(chosen, rewrite);
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the userId on the file points at a user that exists in
|
||||
// this DB. Restore would fail later anyway, but failing here gives
|
||||
// the admin a precise error before any side-effect.
|
||||
@@ -343,19 +615,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
if (!owner) {
|
||||
// Most often an account that was erased. Its id is not written
|
||||
// into the trail: the erasure took every row that named it, and a
|
||||
// refused upload is not a reason to bring it back. The answer says
|
||||
// what the operator can do instead.
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup is for user '${payload.userId}' which does not exist in this DB`,
|
||||
{ reason: "owner_not_found", ownerId: payload.userId },
|
||||
"This file belongs to an account that does not exist on this server. Choose an account to restore it into.",
|
||||
{ reason: "owner_not_found" },
|
||||
{ errorCode: BACKUP_OWNER_MISSING_CODE },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
}
|
||||
// Nothing was stored, so the account made for the copy goes again.
|
||||
if (chosen?.created) await discardCreatedAccount(chosen, actor);
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
// The file was fine; its encrypted copy is larger than the stored-copy
|
||||
// limit (`BACKUP_MAX_STORED_MB`). That is the operator's to act on, with
|
||||
// the numbers and the setting in the message, and not a server fault for
|
||||
@@ -387,6 +663,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
backupId: created.id,
|
||||
ownerId: storedOwner?.id,
|
||||
ownerUsername: storedOwner?.username,
|
||||
// Set when the copy went into a chosen account; the erased id is not.
|
||||
...(chosen ? { into: chosen.created ? "new" : "existing" } : {}),
|
||||
type: uploadType,
|
||||
schemaVersion: summary.schemaVersion,
|
||||
counts: {
|
||||
|
||||
@@ -10,6 +10,10 @@ import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
|
||||
import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window";
|
||||
import {
|
||||
codexCredentialKey,
|
||||
startCodexSignIn,
|
||||
} from "@/lib/ai/codex-slug-cache";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex — device-code poll (admin only).
|
||||
@@ -93,6 +97,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
cookieStore.delete("central_codex_device");
|
||||
// What this process learned about the old sign-in's models no longer
|
||||
// describes a different account (`codex-slug-cache.ts`).
|
||||
startCodexSignIn({
|
||||
key: codexCredentialKey.central,
|
||||
accountId: result.creds.accountId,
|
||||
});
|
||||
// A new central sign-in: every account's ledger rows describe the refused
|
||||
// one, and the central connection is one credential shared by all of them.
|
||||
// #1279 — and every record's note windows close, as on the admin AI
|
||||
|
||||
@@ -5,6 +5,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
codexCredentialKey,
|
||||
forgetCodexSignIn,
|
||||
} from "@/lib/ai/codex-slug-cache";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex (ChatGPT subscription) — status + disconnect.
|
||||
@@ -55,6 +59,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
create: { id: "singleton", adminCodexConnectionStatus: "disconnected" },
|
||||
});
|
||||
|
||||
forgetCodexSignIn(codexCredentialKey.central);
|
||||
|
||||
await auditLog("admin.central-codex.disconnected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
|
||||
import { forgetProviderHealth } from "@/lib/ai/provider-health-ledger";
|
||||
import { clearStatusNegativeWindows } from "@/lib/insights/status-negative-window";
|
||||
import {
|
||||
codexCredentialKey,
|
||||
startCodexSignIn,
|
||||
} from "@/lib/ai/codex-slug-cache";
|
||||
|
||||
/**
|
||||
* v1.4.7.1: Device-code flow polling endpoint.
|
||||
@@ -90,6 +94,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
cookieStore.delete("codex_device");
|
||||
// What this process learned about the old sign-in's models no longer
|
||||
// describes a different account (`codex-slug-cache.ts`).
|
||||
startCodexSignIn({
|
||||
key: codexCredentialKey.user(user.id),
|
||||
accountId: result.creds.accountId,
|
||||
});
|
||||
// A new sign-in: whatever the ledger held against the old one no longer
|
||||
// describes this credential, and (#1279) the person's notes stop waiting
|
||||
// out windows it earned, as on a save of their provider settings.
|
||||
|
||||
@@ -6,6 +6,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { INSIGHTS_CACHE_CLEARED } from "@/lib/insights/cached-text-cipher";
|
||||
import {
|
||||
codexCredentialKey,
|
||||
forgetCodexSignIn,
|
||||
} from "@/lib/ai/codex-slug-cache";
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -27,6 +31,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
forgetCodexSignIn(codexCredentialKey.user(user.id));
|
||||
|
||||
await auditLog("codex.oauth.disconnected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -69,6 +69,7 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
lanes: timeline.lanes.length,
|
||||
items: timeline.lanes.reduce((n, l) => n + l.items.length, 0),
|
||||
series: timeline.series.length,
|
||||
labSeries: timeline.labSeries.length,
|
||||
notable: timeline.notable.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,6 +98,8 @@ function failureText(
|
||||
});
|
||||
case "backup.foreign_reference":
|
||||
return t("admin.section.backups.restoreFailureForeignReference");
|
||||
case "backup.restore.rows_exist_elsewhere":
|
||||
return t("admin.section.backups.restoreFailureRowsElsewhere");
|
||||
case "time_budget":
|
||||
return t("admin.section.backups.restoreFailureTimeBudget");
|
||||
case "transaction_failed":
|
||||
|
||||
@@ -29,7 +29,8 @@ import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status";
|
||||
import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows";
|
||||
import { DatabaseDumpRow } from "./backups/database-dump-row";
|
||||
import { BackupUploadCard } from "./backups/backup-upload-card";
|
||||
import { BackupUpload } from "./backups/backup-upload-card";
|
||||
import { useBackupUpload } from "./backups/use-backup-upload";
|
||||
import {
|
||||
RestoreRefusalNotice,
|
||||
RestoreSkipReport,
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
} from "./backups/restore-notices";
|
||||
import { useBackupDownload, useRunBackup } from "./backups/use-backup-actions";
|
||||
import { useBackupRestores } from "./backups/use-backup-restores";
|
||||
import { useBackupUpload } from "./backups/use-backup-upload";
|
||||
|
||||
export function BackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -55,8 +55,7 @@ export function BackupsSection() {
|
||||
// a fresh proof unless this session signed in or re-proved within minutes.
|
||||
const recentProof = useRecentProof();
|
||||
const { downloadingId, handleDownload } = useBackupDownload(recentProof);
|
||||
const { upload, fileInputRef, handleFileChange } =
|
||||
useBackupUpload(recentProof);
|
||||
const uploadFlow = useBackupUpload(recentProof);
|
||||
const {
|
||||
restoreJobs,
|
||||
dismissedRestores,
|
||||
@@ -126,11 +125,7 @@ export function BackupsSection() {
|
||||
<BackupPassActivityNotice activity={data.scheduleActivity} />
|
||||
) : null}
|
||||
|
||||
<BackupUploadCard
|
||||
pending={upload.isPending}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
<BackupUpload flow={uploadFlow} />
|
||||
|
||||
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
|
||||
<RestoreJobStatus
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The choice offered when an uploaded file's account is not on this server.
|
||||
*
|
||||
* SSR markup per project convention, plus the pure rules the dialog submits
|
||||
* by: a new account needs a valid, free username; an existing one needs the
|
||||
* server's yes for exactly that account and its username typed; managed
|
||||
* profiles and accounts pending deletion are never offered; and each refusal
|
||||
* the upload can answer has its own sentence.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
BackupTargetForm,
|
||||
EMPTY_TARGET_DRAFT,
|
||||
pickableAccounts,
|
||||
readyTarget,
|
||||
targetUploadFailureKey,
|
||||
type BackupTargetCheck,
|
||||
type BackupTargetDraft,
|
||||
} from "../backup-target-dialog";
|
||||
|
||||
function user(overrides: Partial<AdminUser>): AdminUser {
|
||||
return {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
mfaEnforced: false,
|
||||
documentQuotaBytes: null,
|
||||
passkeyCount: 0,
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const USERS = [
|
||||
user({ id: "u1", username: "returning-person", email: "r@example.test" }),
|
||||
user({ id: "u2", username: "managed-cafe01", managedProfile: true }),
|
||||
user({
|
||||
id: "u3",
|
||||
username: "leaving",
|
||||
deletion: { requestedAt: "x", purgeAt: null, requestedBy: "self" },
|
||||
}),
|
||||
user({ id: "u4", username: "operator", role: "ADMIN" }),
|
||||
];
|
||||
|
||||
const ELIGIBLE: BackupTargetCheck = {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
eligible: true,
|
||||
reason: null,
|
||||
models: [],
|
||||
};
|
||||
|
||||
function render(
|
||||
draft: BackupTargetDraft,
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown } = {
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={() => {}}
|
||||
disabled={false}
|
||||
users={USERS}
|
||||
check={check}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("pickableAccounts", () => {
|
||||
it("offers neither managed profiles, accounts pending deletion nor admin accounts", () => {
|
||||
expect(pickableAccounts(USERS).map((u) => u.username)).toEqual([
|
||||
"returning-person",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readyTarget", () => {
|
||||
it("takes a new account with a valid username nobody has", () => {
|
||||
expect(
|
||||
readyTarget(
|
||||
{ ...EMPTY_TARGET_DRAFT, username: " back-again " },
|
||||
USERS,
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ into: "new", username: "back-again" });
|
||||
});
|
||||
|
||||
it("refuses a new username that is taken, malformed or reads as a managed profile", () => {
|
||||
for (const username of ["operator", "no spaces", "ab", "managed-x1"]) {
|
||||
expect(
|
||||
readyTarget({ ...EMPTY_TARGET_DRAFT, username }, USERS, undefined),
|
||||
username,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes an existing account only with the server's yes and its username typed", () => {
|
||||
const draft: BackupTargetDraft = {
|
||||
into: "existing",
|
||||
username: "",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
};
|
||||
expect(readyTarget(draft, USERS, ELIGIBLE)).toEqual({
|
||||
into: "existing",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
});
|
||||
expect(readyTarget(draft, USERS, undefined)).toBeNull();
|
||||
expect(
|
||||
readyTarget(draft, USERS, {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
}),
|
||||
).toBeNull();
|
||||
// A yes for another account does not carry over.
|
||||
expect(readyTarget(draft, USERS, { ...ELIGIBLE, id: "u4" })).toBeNull();
|
||||
expect(
|
||||
readyTarget({ ...draft, confirm: "Returning-Person" }, USERS, ELIGIBLE),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetUploadFailureKey", () => {
|
||||
it("names each refusal the upload answers with, and falls back for the rest", () => {
|
||||
const key = (errorCode?: string) =>
|
||||
targetUploadFailureKey(
|
||||
new ApiError("x", 409, errorCode ? { errorCode } : undefined),
|
||||
);
|
||||
expect(key("backup.target.holds_records")).toBe(
|
||||
"admin.section.backups.target.failureHoldsRecords",
|
||||
);
|
||||
expect(key("backup.target.managed_profile")).toBe(
|
||||
"admin.section.backups.target.failureManagedProfile",
|
||||
);
|
||||
expect(key("backup.target.pending_deletion")).toBe(
|
||||
"admin.section.backups.target.failurePendingDeletion",
|
||||
);
|
||||
expect(key("backup.target.admin_account")).toBe(
|
||||
"admin.section.backups.target.failureAdminAccount",
|
||||
);
|
||||
expect(key("backup.target.not_found")).toBe(
|
||||
"admin.section.backups.target.failureNotFound",
|
||||
);
|
||||
expect(key("backup.target.unconfirmed")).toBe(
|
||||
"admin.section.backups.target.failureUnconfirmed",
|
||||
);
|
||||
expect(key("backup.target.username_taken")).toBe(
|
||||
"admin.section.backups.target.failureUsernameTaken",
|
||||
);
|
||||
expect(key("backup.owner.exists")).toBe(
|
||||
"admin.section.backups.target.failureOwnerExists",
|
||||
);
|
||||
expect(key()).toBe("admin.section.backups.uploadFailed");
|
||||
expect(targetUploadFailureKey(new Error("offline"))).toBe(
|
||||
"admin.section.backups.uploadFailed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<BackupTargetForm>", () => {
|
||||
it("starts on a new account, with the sign-in note", () => {
|
||||
const html = render(EMPTY_TARGET_DRAFT);
|
||||
expect(html).toContain('data-testid="backup-target-username"');
|
||||
expect(html).toContain("created without a password or email");
|
||||
expect(html).not.toContain('data-testid="backup-target-account"');
|
||||
});
|
||||
|
||||
it("says a taken username is taken", () => {
|
||||
const html = render({ ...EMPTY_TARGET_DRAFT, username: "operator" });
|
||||
expect(html).toContain("This username is already taken.");
|
||||
});
|
||||
|
||||
it("offers only pickable accounts, and asks for the username once the server says yes", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{ data: ELIGIBLE, loading: false, error: null },
|
||||
);
|
||||
expect(html).toContain("returning-person (r@example.test)");
|
||||
expect(html).not.toContain(">operator<");
|
||||
expect(html).not.toContain("managed-cafe01");
|
||||
expect(html).not.toContain(">leaving<");
|
||||
expect(html).toContain('data-slot="backup-target-eligible"');
|
||||
expect(html).toContain("Type returning-person to confirm");
|
||||
expect(html).toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
|
||||
it("says why an account that holds records cannot take the copy, and asks nothing more", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{
|
||||
data: {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
expect(html).toContain('data-slot="backup-target-refused"');
|
||||
expect(html).toContain("This account already holds records.");
|
||||
expect(html).not.toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { accountUsernameSchema } from "@/lib/validations/auth";
|
||||
import type { BackupUploadTarget } from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/** What the admin has filled in so far. */
|
||||
export interface BackupTargetDraft {
|
||||
into: "new" | "existing";
|
||||
username: string;
|
||||
userId: string;
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
export const EMPTY_TARGET_DRAFT: BackupTargetDraft = {
|
||||
into: "new",
|
||||
username: "",
|
||||
userId: "",
|
||||
confirm: "",
|
||||
};
|
||||
|
||||
/** The server's answer to "may this account take the copy?". */
|
||||
export interface BackupTargetCheck {
|
||||
id: string;
|
||||
username: string;
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts the picker offers: not a managed profile (its Guardians run
|
||||
* it), not pending deletion and not an admin account (the server refuses all
|
||||
* three, `restore-target.ts`). Whether one holds records is the server's to
|
||||
* say, per account, once it is picked.
|
||||
*/
|
||||
export function pickableAccounts(users: readonly AdminUser[]): AdminUser[] {
|
||||
return users.filter(
|
||||
(user) =>
|
||||
!user.managedProfile && user.deletion === null && user.role !== "ADMIN",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The target the draft describes, or null while it is not one the upload
|
||||
* would take: a new account needs a valid username nobody has, an existing
|
||||
* one needs the server's yes and its username typed exactly.
|
||||
*/
|
||||
export function readyTarget(
|
||||
draft: BackupTargetDraft,
|
||||
users: readonly AdminUser[],
|
||||
check: BackupTargetCheck | undefined,
|
||||
): BackupUploadTarget | null {
|
||||
if (draft.into === "new") {
|
||||
const username = draft.username.trim();
|
||||
if (!accountUsernameSchema.safeParse(username).success) return null;
|
||||
if (users.some((user) => user.username === username)) return null;
|
||||
return { into: "new", username };
|
||||
}
|
||||
const user = users.find((candidate) => candidate.id === draft.userId);
|
||||
if (!user || !check || check.id !== user.id || !check.eligible) return null;
|
||||
if (draft.confirm.trim() !== user.username) return null;
|
||||
return { into: "existing", userId: user.id, confirm: user.username };
|
||||
}
|
||||
|
||||
/** The sentence for a refusal, by the code the server answered with. */
|
||||
export function targetFailureKey(code: string): string {
|
||||
switch (code) {
|
||||
case "backup.target.holds_records":
|
||||
return "admin.section.backups.target.failureHoldsRecords";
|
||||
case "backup.target.managed_profile":
|
||||
return "admin.section.backups.target.failureManagedProfile";
|
||||
case "backup.target.pending_deletion":
|
||||
return "admin.section.backups.target.failurePendingDeletion";
|
||||
case "backup.target.admin_account":
|
||||
return "admin.section.backups.target.failureAdminAccount";
|
||||
case "backup.target.not_found":
|
||||
return "admin.section.backups.target.failureNotFound";
|
||||
case "backup.target.unconfirmed":
|
||||
return "admin.section.backups.target.failureUnconfirmed";
|
||||
case "backup.target.username_taken":
|
||||
return "admin.section.backups.target.failureUsernameTaken";
|
||||
case "backup.owner.exists":
|
||||
return "admin.section.backups.target.failureOwnerExists";
|
||||
default:
|
||||
return "admin.section.backups.uploadFailed";
|
||||
}
|
||||
}
|
||||
|
||||
/** The sentence for a failed upload or check. */
|
||||
export function targetUploadFailureKey(error: unknown): string {
|
||||
return targetFailureKey(
|
||||
error instanceof ApiError && typeof error.meta?.errorCode === "string"
|
||||
? error.meta.errorCode
|
||||
: "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The form inside the dialog, apart from it so it renders on its own: where
|
||||
* the copy of an account that is no longer on this server goes. A new account
|
||||
* by default, since it cannot hold anything yet; an existing one when the
|
||||
* person has signed up again, checked by the server as soon as it is picked.
|
||||
*/
|
||||
export function BackupTargetForm({
|
||||
draft,
|
||||
onChange,
|
||||
disabled,
|
||||
users,
|
||||
check,
|
||||
}: {
|
||||
draft: BackupTargetDraft;
|
||||
onChange: (next: BackupTargetDraft) => void;
|
||||
disabled: boolean;
|
||||
/** Every account; null while the list loads. */
|
||||
users: readonly AdminUser[] | null;
|
||||
/** The server's check of the picked account. */
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown };
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const accounts = pickableAccounts(users ?? []);
|
||||
const chosen = accounts.find((user) => user.id === draft.userId);
|
||||
const username = draft.username.trim();
|
||||
const usernameTaken = (users ?? []).some(
|
||||
(user) => user.username === username,
|
||||
);
|
||||
const usernameInvalid =
|
||||
username !== "" && !accountUsernameSchema.safeParse(username).success;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="backup-target-form">
|
||||
<Segmented
|
||||
label={t("admin.section.backups.target.choiceLabel")}
|
||||
slot="backup-target-choice"
|
||||
stretch
|
||||
disabled={disabled}
|
||||
value={draft.into}
|
||||
onChange={(into) => onChange({ ...draft, into })}
|
||||
options={[
|
||||
{ value: "new", label: t("admin.section.backups.target.newAccount") },
|
||||
{
|
||||
value: "existing",
|
||||
label: t("admin.section.backups.target.existingAccount"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{draft.into === "new" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-username">
|
||||
{t("admin.section.backups.target.usernameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-username"
|
||||
data-testid="backup-target-username"
|
||||
value={draft.username}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...draft, username: e.target.value })}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={usernameInvalid || usernameTaken}
|
||||
/>
|
||||
{usernameTaken ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.failureUsernameTaken")}
|
||||
</p>
|
||||
) : usernameInvalid ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.usernameInvalid")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.newAccountHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-account">
|
||||
{t("admin.section.backups.target.accountLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="backup-target-account"
|
||||
data-testid="backup-target-account"
|
||||
value={draft.userId}
|
||||
disabled={disabled || users === null}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, userId: e.target.value, confirm: "" })
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.section.backups.target.accountPlaceholder")}
|
||||
</option>
|
||||
{accounts.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email
|
||||
? `${user.username} (${user.email})`
|
||||
: user.username}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{chosen && check.loading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.checking")}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t(targetUploadFailureKey(check.error))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data && !check.data.eligible ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-xs"
|
||||
data-slot="backup-target-refused"
|
||||
>
|
||||
{t(targetFailureKey(`backup.target.${check.data.reason}`))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data?.eligible ? (
|
||||
<>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="backup-target-eligible"
|
||||
>
|
||||
{t("admin.section.backups.target.eligible")}
|
||||
</p>
|
||||
<Label htmlFor="backup-target-confirm">
|
||||
{t("admin.section.backups.target.confirmLabel", {
|
||||
username: chosen.username,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-confirm"
|
||||
data-testid="backup-target-confirm"
|
||||
value={draft.confirm}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, confirm: e.target.value })
|
||||
}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={chosen.username}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when an upload was refused because the account the file belongs to
|
||||
* is not on this server. Choosing a target sends the file again with it; the
|
||||
* server rewrites the file's owner to that account as it stores it.
|
||||
*/
|
||||
export function BackupTargetDialog({
|
||||
file,
|
||||
pending,
|
||||
error,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
/** The refused file; the dialog is open while there is one. */
|
||||
file: File | null;
|
||||
pending: boolean;
|
||||
/** The last refusal of an upload into a target, shown in the dialog. */
|
||||
error: unknown;
|
||||
onSubmit: (target: BackupUploadTarget) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [draft, setDraft] = useState<BackupTargetDraft>(EMPTY_TARGET_DRAFT);
|
||||
const users = useQuery({
|
||||
queryKey: queryKeys.adminUsers(),
|
||||
queryFn: () => apiGet<AdminUser[]>("/api/admin/users"),
|
||||
enabled: file !== null,
|
||||
});
|
||||
const check = useQuery({
|
||||
queryKey: queryKeys.adminBackupTarget(draft.userId),
|
||||
queryFn: () =>
|
||||
apiGet<BackupTargetCheck>(
|
||||
`/api/admin/backups/targets/${encodeURIComponent(draft.userId)}`,
|
||||
),
|
||||
enabled: file !== null && draft.into === "existing" && draft.userId !== "",
|
||||
retry: false,
|
||||
});
|
||||
const target = readyTarget(draft, users.data ?? [], check.data);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={file !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (next || pending) return;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-slot="backup-target-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.backups.target.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.backups.target.description", {
|
||||
file: file?.name ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{file ? (
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={setDraft}
|
||||
disabled={pending}
|
||||
users={users.isError ? [] : (users.data ?? null)}
|
||||
check={{
|
||||
data: check.data,
|
||||
loading: check.isLoading,
|
||||
error: check.error,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{error && !pending ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(targetUploadFailureKey(error))}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
data-testid="backup-target-submit"
|
||||
disabled={!target || pending}
|
||||
onClick={() => {
|
||||
if (target) onSubmit(target);
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{pending
|
||||
? t("admin.section.backups.target.submitting")
|
||||
: t("admin.section.backups.target.submit")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,55 @@
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { BackupTargetDialog } from "./backup-target-dialog";
|
||||
import type { useBackupUpload } from "./use-backup-upload";
|
||||
|
||||
/**
|
||||
* The upload card, and for a file whose account is not on this server, the
|
||||
* dialog that sends it again into an account the admin picks. The dialog is
|
||||
* mounted per refused file, so every file starts from a clean form.
|
||||
*/
|
||||
export function BackupUpload({
|
||||
flow,
|
||||
}: {
|
||||
flow: ReturnType<typeof useBackupUpload>;
|
||||
}) {
|
||||
const {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
} = flow;
|
||||
return (
|
||||
<>
|
||||
<BackupUploadCard
|
||||
pending={upload.isPending || uploadInto.isPending}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
{orphan ? (
|
||||
<BackupTargetDialog
|
||||
file={orphan}
|
||||
pending={uploadInto.isPending}
|
||||
error={
|
||||
uploadInto.error instanceof ReproofCancelledError
|
||||
? null
|
||||
: uploadInto.error
|
||||
}
|
||||
onSubmit={(target) => uploadInto.mutate(target)}
|
||||
onClose={closeOrphan}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The upload card: separate from the table so admins can ingest a backup file
|
||||
* independently of any existing rows. The visible button proxies a hidden
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
backupUploadTargetQuery,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/**
|
||||
* The upload body for a backup file: the file itself when it is already
|
||||
@@ -28,9 +33,11 @@ async function uploadBody(file: File): Promise<Blob> {
|
||||
* and a large export (hundreds of MB of JSON) shrinks about tenfold. No request
|
||||
* timeout, because a file of that size takes longer to check and store than
|
||||
* the default allows. The body is built here, per call, so a retry after a
|
||||
* re-proof sends the file again rather than a spent stream.
|
||||
* re-proof sends the file again rather than a spent stream. With a target, the
|
||||
* copy goes into that account instead of the one the file names, which is
|
||||
* how the copy of an account no longer on this server comes back.
|
||||
*/
|
||||
async function uploadOnce(file: File) {
|
||||
async function uploadOnce(file: File, target?: BackupUploadTarget) {
|
||||
const body = await uploadBody(file);
|
||||
return apiFetch<{
|
||||
id: string;
|
||||
@@ -65,12 +72,17 @@ async function uploadOnce(file: File) {
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
});
|
||||
}>(
|
||||
target
|
||||
? `/api/admin/backups/upload?${backupUploadTargetQuery(target)}`
|
||||
: "/api/admin/backups/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type UploadSummary = Awaited<ReturnType<typeof uploadOnce>>["summary"];
|
||||
@@ -106,9 +118,19 @@ export function uploadedRecordCount(summary: UploadSummary): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an upload was refused because the file's account is not here. */
|
||||
export function isOwnerMissing(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === BACKUP_OWNER_MISSING_CODE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-file upload flow: a fresh proof first, then the file, and the
|
||||
* file input reset after either outcome.
|
||||
* file input reset after either outcome. A file whose account is not on this
|
||||
* server is kept (`orphan`) for the target dialog, which sends it again into
|
||||
* the account the admin picks (`uploadInto`).
|
||||
*/
|
||||
export function useBackupUpload(
|
||||
recentProof: ReturnType<typeof useRecentProof>,
|
||||
@@ -118,21 +140,32 @@ export function useBackupUpload(
|
||||
|
||||
// Upload state — single-file flow, drives the file input + button.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [orphan, setOrphan] = useState<File | null>(null);
|
||||
|
||||
/** A stored copy, said once: for its own account, or for the one chosen. */
|
||||
function announceStored(summary: UploadSummary, username: string | null) {
|
||||
const count = String(uploadedRecordCount(summary));
|
||||
toast.success(
|
||||
username === null
|
||||
? t("admin.section.backups.uploadSuccess", { count })
|
||||
: t("admin.section.backups.target.success", { count, username }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
}
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
// Ask first, so a large file is not sent once only to be refused.
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
return uploadOnce(file);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const total = uploadedRecordCount(data.summary);
|
||||
toast.success(
|
||||
t("admin.section.backups.uploadSuccess", { count: String(total) }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
onSuccess: (data) => announceStored(data.summary, null),
|
||||
onError: (err, file) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
// Not a dead end: the dialog offers an account to restore it into.
|
||||
if (isOwnerMissing(err)) {
|
||||
setOrphan(file);
|
||||
return;
|
||||
}
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
@@ -147,10 +180,39 @@ export function useBackupUpload(
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInto = useMutation({
|
||||
mutationFn: async (target: BackupUploadTarget) => {
|
||||
if (!orphan) throw new Error("No file to upload");
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
return { data: await uploadOnce(orphan, target), target };
|
||||
},
|
||||
onSuccess: ({ data, target }) => {
|
||||
setOrphan(null);
|
||||
announceStored(
|
||||
data.summary,
|
||||
target.into === "new" ? target.username : target.confirm,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
|
||||
},
|
||||
// A refusal stays in the dialog, beside the choice it refuses.
|
||||
});
|
||||
|
||||
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload.mutate(file);
|
||||
}
|
||||
|
||||
return { upload, fileInputRef, handleFileChange };
|
||||
function closeOrphan() {
|
||||
setOrphan(null);
|
||||
uploadInto.reset();
|
||||
}
|
||||
|
||||
return {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,8 @@ function timeline(): TimelineResponse {
|
||||
standing: [],
|
||||
series: [],
|
||||
availableSeries: [],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
notable: [],
|
||||
} as unknown as TimelineResponse;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Lab markers on the timeline (#1299), as laid out and as rendered: a row
|
||||
* per marker under the value lines with its readings as single points, the
|
||||
* reference range behind them, a reading outside it drawn as a triangle and
|
||||
* worded as outside wherever it is written out, the report it came from,
|
||||
* the readings a unit left out named, and the menu's groups. All data
|
||||
* invented.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { TimelineLabSeries, TimelineResponse } from "@/lib/day/contract";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
LabExcludedNote,
|
||||
LabLines,
|
||||
LabSeriesTable,
|
||||
labReadingsIn,
|
||||
} from "../lab-lines";
|
||||
import { SelectionBar } from "../selection-bar";
|
||||
import type { SeriesValueFormat } from "../series-format";
|
||||
import { SERIES_HEIGHT } from "../series-geometry";
|
||||
import { layoutTimeline } from "../timeline-geometry";
|
||||
import { TimelineChronicle } from "../timeline-chronicle";
|
||||
import { TimelineLegend } from "../timeline-legend";
|
||||
import { labOptionGroups } from "../timeline-menus";
|
||||
import { useSeriesValueFormat } from "../use-series-value-format";
|
||||
import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
const FERRITIN: TimelineLabSeries = {
|
||||
key: "lab:bm-ferritin",
|
||||
biomarkerId: "bm-ferritin",
|
||||
name: "Ferritin",
|
||||
panel: "Eisen",
|
||||
unit: "ng/mL",
|
||||
band: { low: 30, high: 400 },
|
||||
points: [
|
||||
{
|
||||
id: "r1",
|
||||
date: "2024-02-12",
|
||||
value: 18,
|
||||
sourceValue: null,
|
||||
rangeStatus: "below",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 400,
|
||||
report: {
|
||||
id: "d1",
|
||||
title: "Befund Februar",
|
||||
href: "/documents?doc=d1",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
date: "2025-06-01",
|
||||
value: 85,
|
||||
sourceValue: null,
|
||||
rangeStatus: "in-range",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 400,
|
||||
report: null,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
date: "2026-03-02",
|
||||
value: 420,
|
||||
sourceValue: null,
|
||||
rangeStatus: "above",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 400,
|
||||
report: null,
|
||||
},
|
||||
],
|
||||
excluded: 0,
|
||||
};
|
||||
|
||||
const HBA1C: TimelineLabSeries = {
|
||||
key: "lab:bm-hba1c",
|
||||
biomarkerId: "bm-hba1c",
|
||||
name: "HbA1c",
|
||||
panel: null,
|
||||
unit: "%",
|
||||
band: null,
|
||||
points: [
|
||||
{
|
||||
id: "h1",
|
||||
date: "2026-03-02",
|
||||
value: 5.4,
|
||||
sourceValue: null,
|
||||
rangeStatus: "unknown",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
report: null,
|
||||
},
|
||||
],
|
||||
excluded: 2,
|
||||
};
|
||||
|
||||
const WINDOW = { from: "2024-01-01", to: "2026-06-30" };
|
||||
|
||||
function layout(labSeries: readonly TimelineLabSeries[]) {
|
||||
const timeline = fullTimeline();
|
||||
return layoutTimeline({
|
||||
width: 900,
|
||||
window: WINDOW,
|
||||
lanes: timeline.lanes,
|
||||
series: timeline.series,
|
||||
labSeries,
|
||||
bucket: "quarter",
|
||||
words: wordsIn("de"),
|
||||
startMissing: "?",
|
||||
today: TODAY,
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactElement, locale: "de" | "en" = "de") {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The text a reader sees, runs kept apart. */
|
||||
function textOf(html: string): string {
|
||||
let out = "";
|
||||
let inTag = false;
|
||||
for (const ch of html) {
|
||||
if (ch === "<") inTag = true;
|
||||
else if (ch === ">") {
|
||||
inTag = false;
|
||||
out += " ";
|
||||
} else if (!inTag) out += ch;
|
||||
}
|
||||
return out.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function WithFormat({
|
||||
build,
|
||||
}: {
|
||||
build: (format: SeriesValueFormat) => React.ReactNode;
|
||||
}) {
|
||||
return <>{build(useSeriesValueFormat())}</>;
|
||||
}
|
||||
|
||||
describe("the lab rows' geometry", () => {
|
||||
it("adds a row per marker under the value lines and leaves the rest as it was", () => {
|
||||
const without = layout([]);
|
||||
const withLabs = layout([FERRITIN, HBA1C]);
|
||||
expect(withLabs.series).toEqual(without.series);
|
||||
expect(withLabs.lanes).toEqual(without.lanes);
|
||||
expect(withLabs.height - without.height).toBe(2 * SERIES_HEIGHT);
|
||||
const [ferritin, hba1c] = withLabs.labSeries;
|
||||
expect(ferritin.top).toBe(
|
||||
without.seriesTop + without.series.length * SERIES_HEIGHT + 6,
|
||||
);
|
||||
expect(hba1c.top - ferritin.top).toBe(SERIES_HEIGHT);
|
||||
});
|
||||
|
||||
it("places each reading on its own day, inside the plot and inside its row", () => {
|
||||
const { labSeries, scale } = layout([FERRITIN]);
|
||||
const [row] = labSeries;
|
||||
expect(row.points.map((p) => p.id)).toEqual(["r1", "r2", "r3"]);
|
||||
for (const p of row.points) {
|
||||
expect(p.x).toBeCloseTo(scale.xMid(p.date), 5);
|
||||
expect(p.x).toBeGreaterThanOrEqual(scale.x0);
|
||||
expect(p.x).toBeLessThanOrEqual(scale.x1);
|
||||
expect(p.y).toBeGreaterThanOrEqual(row.top);
|
||||
expect(p.y).toBeLessThanOrEqual(row.top + row.height);
|
||||
}
|
||||
// Higher values sit higher; the readings are joined in time order.
|
||||
const [low, mid, high] = row.points;
|
||||
expect(low.y).toBeGreaterThan(mid.y);
|
||||
expect(mid.y).toBeGreaterThan(high.y);
|
||||
expect(row.path.match(/[ML]/g)).toEqual(["M", "L", "L"]);
|
||||
expect(row.latest?.id).toBe("r3");
|
||||
});
|
||||
|
||||
it("holds the reference range in the scale and paints it behind the points", () => {
|
||||
const [row] = layout([FERRITIN]).labSeries;
|
||||
expect(row.band).not.toBeNull();
|
||||
const { top, bottom } = row.band!;
|
||||
expect(top).not.toBeNull();
|
||||
expect(bottom).not.toBeNull();
|
||||
// The in-range reading lies inside the band, the others outside it.
|
||||
const [low, mid, high] = row.points;
|
||||
expect(mid.y).toBeGreaterThan(top!);
|
||||
expect(mid.y).toBeLessThan(bottom!);
|
||||
expect(low.y).toBeGreaterThan(bottom!);
|
||||
expect(high.y).toBeLessThan(top!);
|
||||
});
|
||||
|
||||
it("draws one reading as a point with no line, and an empty window as no point", () => {
|
||||
const [single] = layout([HBA1C]).labSeries;
|
||||
expect(single.points).toHaveLength(1);
|
||||
expect(single.path).toBe("");
|
||||
expect(single.band).toBeNull();
|
||||
expect(single.points[0].y).toBeCloseTo(single.top + single.height / 2, 5);
|
||||
const [empty] = layout([{ ...FERRITIN, points: [] }]).labSeries;
|
||||
expect(empty.points).toEqual([]);
|
||||
expect(empty.latest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lab rows as drawn", () => {
|
||||
function drawn(rows: readonly TimelineLabSeries[]) {
|
||||
const l = layout(rows);
|
||||
return render(
|
||||
<svg>
|
||||
<LabLines
|
||||
layout={l}
|
||||
selectedBucket="2025-04-01"
|
||||
bucket="quarter"
|
||||
seriesLabel={(key) => (key === FERRITIN.key ? "Ferritin" : "HbA1c")}
|
||||
seriesColor={() => "var(--chart-3)"}
|
||||
valueText={(value, _source, unit) => `${value} ${unit}`}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
}
|
||||
|
||||
it("marks a reading outside its range by shape, the way it left", () => {
|
||||
const html = drawn([FERRITIN]);
|
||||
expect(html).toContain('data-status="below"');
|
||||
expect(html).toContain('data-status="above"');
|
||||
// Outside the range a triangle (a path), inside it a dot.
|
||||
expect(html).toMatch(/<path d="M[^"]+Z"[^>]*data-status="below"/);
|
||||
expect(html).toMatch(/<path d="M[^"]+Z"[^>]*data-status="above"/);
|
||||
expect(html).toMatch(/<circle[^>]*data-status="in-range"/);
|
||||
// The band and its dashed edges, in the row's own colour.
|
||||
expect(html).toContain('data-slot="timeline-lab-band-fill"');
|
||||
expect(html.match(/data-slot="timeline-lab-band-edge"/g)).toHaveLength(2);
|
||||
expect(html).toContain('data-color="var(--chart-3)"');
|
||||
// The newest reading beside the name; the selected bucket's ringed.
|
||||
expect(textOf(html)).toContain("420 ng/mL");
|
||||
expect(html.match(/data-slot="timeline-series-selected"/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says so when the window holds no reading", () => {
|
||||
const html = drawn([{ ...FERRITIN, points: [] }]);
|
||||
expect(textOf(html)).toContain("Keine Werte in diesem Zeitraum");
|
||||
expect(html).not.toContain('data-slot="timeline-lab-mark"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lab readings written out", () => {
|
||||
function bar(selected: string, locale: "de" | "en" = "en") {
|
||||
const timeline: TimelineResponse = {
|
||||
...fullTimeline(),
|
||||
bucket: "quarter",
|
||||
labSeries: [FERRITIN, HBA1C],
|
||||
};
|
||||
return render(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<SelectionBar
|
||||
timeline={timeline}
|
||||
selected={selected}
|
||||
today={TODAY}
|
||||
seriesLabel={(key) =>
|
||||
key === FERRITIN.key
|
||||
? "Ferritin"
|
||||
: key === HBA1C.key
|
||||
? "HbA1c"
|
||||
: key
|
||||
}
|
||||
seriesColor={() => "var(--chart-3)"}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => undefined}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
it("lists the bucket's readings with their day, a word outside the range and the report", () => {
|
||||
const html = bar("2024-02-20");
|
||||
const text = textOf(html);
|
||||
expect(text).toContain("Ferritin 18 ng/mL, below range, Feb 12");
|
||||
expect(html).toMatch(
|
||||
/<a[^>]*href="\/documents\?doc=d1"[^>]*>Report: Befund Februar<\/a>/,
|
||||
);
|
||||
const both = textOf(bar("2026-03-10"));
|
||||
expect(both).toContain("Ferritin 420 ng/mL, above range, Mar 2");
|
||||
// In range and without a range: the value alone.
|
||||
expect(both).toContain("HbA1c 5.4 %, Mar 2");
|
||||
expect(both).not.toContain("HbA1c 5.4 %, above");
|
||||
});
|
||||
|
||||
it("words the verdict in the reader's language", () => {
|
||||
expect(textOf(bar("2024-02-20", "de"))).toContain(
|
||||
"Ferritin 18 ng/mL, unter dem Referenzbereich",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds the readings of a stretch on their own days", () => {
|
||||
expect(
|
||||
labReadingsIn([FERRITIN, HBA1C], "2026-03-02", "2026-03-02").map(
|
||||
(r) => r.point.id,
|
||||
),
|
||||
).toEqual(["r3", "h1"]);
|
||||
expect(labReadingsIn([FERRITIN], "2023-01-01", "2023-12-31")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rides on the phone's lab-day row, with no link inside the row's button", () => {
|
||||
const timeline: TimelineResponse = {
|
||||
...fullTimeline(),
|
||||
bucket: "quarter",
|
||||
lanes: [
|
||||
{
|
||||
key: "labs",
|
||||
items: [
|
||||
item({
|
||||
id: "lab-day:2024-02-12",
|
||||
kind: "labDay",
|
||||
start: "2024-02-12",
|
||||
label: "Ferritin",
|
||||
sub: "1",
|
||||
href: "/labs",
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
standing: [],
|
||||
labSeries: [FERRITIN],
|
||||
};
|
||||
const html = render(
|
||||
<WithFormat
|
||||
build={(format) => (
|
||||
<TimelineChronicle
|
||||
timeline={timeline}
|
||||
today={TODAY}
|
||||
grouping="month"
|
||||
selected={null}
|
||||
seriesColor={() => "var(--chart-3)"}
|
||||
seriesFormat={format}
|
||||
onOpenDay={() => undefined}
|
||||
onEditLifeEvent={null}
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
"en",
|
||||
);
|
||||
const row = html.slice(html.indexOf('data-slot="timeline-chronicle-labs"'));
|
||||
expect(textOf(row)).toContain("Ferritin 18 ng/mL, below range");
|
||||
expect(html).not.toContain('data-slot="timeline-lab-report"');
|
||||
});
|
||||
|
||||
it("names the readings a unit left out, and lists every reading for a screen reader", () => {
|
||||
const note = textOf(
|
||||
render(
|
||||
<LabExcludedNote
|
||||
labSeries={[FERRITIN, HBA1C]}
|
||||
label={(key) => (key === HBA1C.key ? "HbA1c" : "Ferritin")}
|
||||
/>,
|
||||
"en",
|
||||
),
|
||||
);
|
||||
expect(note).toContain("HbA1c: 2 values in another unit are not drawn");
|
||||
expect(note).not.toContain("Ferritin");
|
||||
|
||||
const table = render(
|
||||
<LabSeriesTable
|
||||
labSeries={[FERRITIN]}
|
||||
window={WINDOW}
|
||||
label={() => "Ferritin"}
|
||||
valueText={(value, _source, unit) => `${value} ${unit}`}
|
||||
intl="en-GB"
|
||||
/>,
|
||||
"en",
|
||||
);
|
||||
const rows = table.match(/<tr>/g) ?? [];
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(textOf(table)).toContain(
|
||||
"18 ng/mL, below range, reference 30–400 ng/mL, Report: Befund Februar",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains its marks in the legend only when a lab line is drawn", () => {
|
||||
const without = render(<TimelineLegend bucket="quarter" hasSeries />);
|
||||
expect(without).not.toContain("timeline-legend-lab");
|
||||
const withLabs = textOf(
|
||||
render(
|
||||
<TimelineLegend bucket="quarter" hasSeries labSeries={[FERRITIN]} />,
|
||||
"en",
|
||||
),
|
||||
);
|
||||
expect(withLabs).toContain("Lab value, as measured");
|
||||
expect(withLabs).toContain("Outside the reference range");
|
||||
expect(withLabs).toContain("Reference range");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lab markers in the menu", () => {
|
||||
it("groups by the labs list's group, named groups first, in the reader's order", () => {
|
||||
const groups = labOptionGroups(
|
||||
[
|
||||
{ key: "lab:c", name: "Zink", panel: null },
|
||||
{ key: "lab:b", name: "Transferrin", panel: "Eisen" },
|
||||
{ key: "lab:a", name: "Ferritin", panel: "Eisen" },
|
||||
{ key: "lab:d", name: "Ärzte-Wert", panel: "Blutbild" },
|
||||
{ key: "lab:a", name: "Ferritin", panel: "Eisen" },
|
||||
],
|
||||
"de-DE",
|
||||
);
|
||||
expect(groups).toEqual([
|
||||
{ panel: "Blutbild", keys: ["lab:d"] },
|
||||
{ panel: "Eisen", keys: ["lab:a", "lab:b"] },
|
||||
{ panel: null, keys: ["lab:c"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,8 @@ const day = () => `2026-09-${String(nextDay++).padStart(2, "0")}`;
|
||||
/** Every timeline item kind, with every code its second line can carry. */
|
||||
function everyKind(): TimelineResponse {
|
||||
nextDay = 1;
|
||||
// Taken in its place among the days, so the other items keep theirs.
|
||||
let labDay = "";
|
||||
const life: TimelineItem[] = [
|
||||
...LIFE_EVENT_CATEGORIES.map((category, i) =>
|
||||
item({
|
||||
@@ -235,7 +237,7 @@ function everyKind(): TimelineResponse {
|
||||
item({
|
||||
id: "lab-day",
|
||||
kind: "labDay",
|
||||
start: day(),
|
||||
start: (labDay = day()),
|
||||
label: "Kreatinin, Kalium, Natrium",
|
||||
sub: "5",
|
||||
}),
|
||||
@@ -258,6 +260,32 @@ function everyKind(): TimelineResponse {
|
||||
standing: [],
|
||||
series: [],
|
||||
availableSeries: [],
|
||||
// A lab line (#1299) with a reading of every range verdict on the lab
|
||||
// day: each is worded, none reaches the screen as its code.
|
||||
labSeries: [
|
||||
{
|
||||
key: "lab:bm-k",
|
||||
biomarkerId: "bm-k",
|
||||
name: "Kalium",
|
||||
panel: null,
|
||||
unit: "mmol/L",
|
||||
band: { low: 3.5, high: 5.1 },
|
||||
points: (["below", "in-range", "above", "unknown"] as const).map(
|
||||
(rangeStatus, i) => ({
|
||||
id: `k${i}`,
|
||||
date: labDay,
|
||||
value: 3 + i,
|
||||
sourceValue: null,
|
||||
rangeStatus,
|
||||
referenceLow: 3.5,
|
||||
referenceHigh: 5.1,
|
||||
report: null,
|
||||
}),
|
||||
),
|
||||
excluded: 1,
|
||||
},
|
||||
],
|
||||
availableLabs: [],
|
||||
notable: [],
|
||||
};
|
||||
// The fixture itself must reach every kind, or the guard proves less.
|
||||
@@ -316,6 +344,10 @@ describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
|
||||
),
|
||||
);
|
||||
expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values");
|
||||
expect(text).toContain(
|
||||
locale === "de" ? "über dem Referenzbereich" : "above range",
|
||||
);
|
||||
expect(text).not.toMatch(/in-range|unknown/);
|
||||
expect(rawCodes(text)).toEqual([]);
|
||||
expect(text).not.toContain(MIDDLE_DOT);
|
||||
});
|
||||
|
||||
@@ -284,6 +284,8 @@ export function fullTimeline(): TimelineResponse {
|
||||
},
|
||||
],
|
||||
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
itemsInPeriod,
|
||||
latestDataDate,
|
||||
layoutLane,
|
||||
layoutSeries,
|
||||
layoutTimeline,
|
||||
stepSelection,
|
||||
todayLabelFits,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
type LaneLayout,
|
||||
type TimelineLayout,
|
||||
} from "../timeline-geometry";
|
||||
import { layoutSeries } from "../series-geometry";
|
||||
import { itemLine } from "../item-words";
|
||||
import { dayNumber } from "../timeline-dates";
|
||||
import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture";
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Lab markers on the timeline page (#1299), in a real browser: the page as
|
||||
* mounted, its reads answered by a stubbed network.
|
||||
*
|
||||
* - A stored choice that names a lab marker reaches the request, and the
|
||||
* marker is drawn as its readings under the value lines.
|
||||
* - The values menu offers the record's markers under their groups;
|
||||
* choosing one adds it to the stored list and to the next request,
|
||||
* unchoosing removes it from both.
|
||||
* - With the labs module off, no marker is offered or requested.
|
||||
* - A reading's point reads on its own by keyboard: day, value, the word
|
||||
* for a reading outside its range, the range and the report.
|
||||
* - At 390 px the page does not scroll sideways, the values menu is an
|
||||
* icon button with a name, and the lab day's row lists the readings.
|
||||
*
|
||||
* All data invented.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { page, userEvent } from "vitest/browser";
|
||||
|
||||
import type { TimelineResponse } from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
stubFetch,
|
||||
type FetchStub,
|
||||
} from "../../../../tests/browser/fetch-stub";
|
||||
import { renderWithApp } from "../../../../tests/browser/render";
|
||||
import { setSearch } from "../../../../tests/browser/url";
|
||||
import { TimelineView } from "../timeline-view";
|
||||
|
||||
const VALUES_KEY = "healthlog.timeline.values";
|
||||
|
||||
function timeline(values: readonly string[]): TimelineResponse {
|
||||
const ferritin = values.includes("lab:bm-ferritin");
|
||||
return {
|
||||
zoom: "all",
|
||||
range: { from: "2024-01-01", to: "2026-06-30", dataFrom: "2024-01-01" },
|
||||
bucket: "quarter",
|
||||
lanes: [
|
||||
{
|
||||
key: "labs",
|
||||
items: [
|
||||
{
|
||||
id: "lab-day:2024-02-12",
|
||||
kind: "labDay",
|
||||
start: "2024-02-12",
|
||||
end: null,
|
||||
open: false,
|
||||
precision: "DAY",
|
||||
startKnown: true,
|
||||
label: "Ferritin",
|
||||
sub: "1",
|
||||
href: "/labs",
|
||||
group: null,
|
||||
},
|
||||
{
|
||||
id: "lab-day:2026-03-02",
|
||||
kind: "labDay",
|
||||
start: "2026-03-02",
|
||||
end: null,
|
||||
open: false,
|
||||
precision: "DAY",
|
||||
startKnown: true,
|
||||
label: "Ferritin, HbA1c",
|
||||
sub: "2",
|
||||
href: "/labs",
|
||||
group: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
standing: [],
|
||||
series: values.includes("WEIGHT")
|
||||
? [
|
||||
{
|
||||
key: "WEIGHT",
|
||||
unit: "kg",
|
||||
points: [
|
||||
{ t: "2025-01-01", mean: 82.4, count: 6 },
|
||||
{ t: "2025-04-01", mean: 81.9, count: 5 },
|
||||
],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
availableSeries: ["WEIGHT"],
|
||||
labSeries: ferritin
|
||||
? [
|
||||
{
|
||||
key: "lab:bm-ferritin",
|
||||
biomarkerId: "bm-ferritin",
|
||||
name: "Ferritin",
|
||||
panel: "Iron",
|
||||
unit: "ng/mL",
|
||||
band: { low: 30, high: 400 },
|
||||
points: [
|
||||
{
|
||||
id: "r1",
|
||||
date: "2024-02-12",
|
||||
value: 18,
|
||||
sourceValue: null,
|
||||
rangeStatus: "below",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 400,
|
||||
report: {
|
||||
id: "d1",
|
||||
title: "February report",
|
||||
href: "/documents?doc=d1",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
date: "2026-03-02",
|
||||
value: 120,
|
||||
sourceValue: null,
|
||||
rangeStatus: "in-range",
|
||||
referenceLow: 30,
|
||||
referenceHigh: 400,
|
||||
report: null,
|
||||
},
|
||||
],
|
||||
excluded: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
availableLabs: [
|
||||
{
|
||||
key: "lab:bm-hba1c",
|
||||
biomarkerId: "bm-hba1c",
|
||||
name: "HbA1c",
|
||||
panel: "Glucose",
|
||||
unit: "%",
|
||||
},
|
||||
{
|
||||
key: "lab:bm-ferritin",
|
||||
biomarkerId: "bm-ferritin",
|
||||
name: "Ferritin",
|
||||
panel: "Iron",
|
||||
unit: "ng/mL",
|
||||
},
|
||||
],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The `values` a request named, as a list. */
|
||||
function valuesOf(path: string): string[] {
|
||||
const raw = new URL(path, window.location.origin).searchParams.get("values");
|
||||
return raw ? raw.split(",") : [];
|
||||
}
|
||||
|
||||
function stubTimeline(): FetchStub {
|
||||
return stubFetch()
|
||||
.on("GET", "/api/timeline", (request) => ({
|
||||
data: timeline(valuesOf(request.path)),
|
||||
}))
|
||||
.on("GET", "/api/timeline/readiness", {
|
||||
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
|
||||
})
|
||||
.on("GET", "/api/life-events", { data: { events: [] } });
|
||||
}
|
||||
|
||||
async function mount(
|
||||
stored: string[] | null,
|
||||
modules: Record<string, boolean> = { timeline: true, labs: true },
|
||||
) {
|
||||
if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
|
||||
setSearch("?zoom=all");
|
||||
const net = stubTimeline();
|
||||
const { screen } = await renderWithApp(<TimelineView />, {
|
||||
user: { timezone: "Europe/Berlin", modules },
|
||||
});
|
||||
return { net, screen };
|
||||
}
|
||||
|
||||
function lastTimelineValues(net: FetchStub): string[] {
|
||||
const calls = net.calls("GET", "/api/timeline");
|
||||
return valuesOf(calls[calls.length - 1]?.path ?? "");
|
||||
}
|
||||
|
||||
describe("lab lines on the timeline page", () => {
|
||||
it("asks for a stored lab marker and draws it as its readings", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net } = await mount(["WEIGHT", "lab:bm-ferritin"]);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
document.querySelectorAll(
|
||||
'[data-lab-series="lab:bm-ferritin"] [data-slot="timeline-lab-mark"]',
|
||||
).length,
|
||||
)
|
||||
.toBe(2);
|
||||
expect(lastTimelineValues(net)).toEqual(["WEIGHT", "lab:bm-ferritin"]);
|
||||
const below = document.querySelector(
|
||||
'[data-lab-series="lab:bm-ferritin"] [data-status="below"]',
|
||||
);
|
||||
expect(below?.tagName.toLowerCase()).toBe("path");
|
||||
// The value line keeps its own row above it.
|
||||
expect(document.querySelector('[data-series="WEIGHT"]')).not.toBeNull();
|
||||
// The name leads to the marker's page.
|
||||
const link = document.querySelector(
|
||||
'[data-slot="timeline-series-link"][data-series="lab:bm-ferritin"]',
|
||||
);
|
||||
expect(link?.getAttribute("href")).toBe("/labs/bm-ferritin");
|
||||
});
|
||||
|
||||
it("offers the record's markers under their groups, and a choice persists both ways", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net, screen } = await mount(["WEIGHT"]);
|
||||
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
|
||||
await expect.element(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await expect.element(screen.getByText("Lab values")).toBeVisible();
|
||||
await expect.element(screen.getByText("Glucose")).toBeVisible();
|
||||
await expect.element(screen.getByText("Iron")).toBeVisible();
|
||||
const ferritin = screen.getByRole("menuitemcheckbox", { name: "Ferritin" });
|
||||
await expect.element(ferritin).toHaveAttribute("aria-checked", "false");
|
||||
await ferritin.click();
|
||||
await expect.element(ferritin).toHaveAttribute("aria-checked", "true");
|
||||
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([
|
||||
"WEIGHT",
|
||||
"lab:bm-ferritin",
|
||||
]);
|
||||
await expect
|
||||
.poll(() => lastTimelineValues(net))
|
||||
.toEqual(["WEIGHT", "lab:bm-ferritin"]);
|
||||
|
||||
await ferritin.click();
|
||||
await expect.element(ferritin).toHaveAttribute("aria-checked", "false");
|
||||
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([
|
||||
"WEIGHT",
|
||||
]);
|
||||
await expect.poll(() => lastTimelineValues(net)).toEqual(["WEIGHT"]);
|
||||
});
|
||||
|
||||
it("keeps a chosen marker that was hidden since, until it is unticked", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
window.localStorage.setItem(
|
||||
VALUES_KEY,
|
||||
JSON.stringify(["lab:bm-ferritin"]),
|
||||
);
|
||||
setSearch("?zoom=all");
|
||||
// Hidden: the server no longer offers it, and still draws the chosen line.
|
||||
const net = stubFetch()
|
||||
.on("GET", "/api/timeline", (request) => {
|
||||
const body = timeline(valuesOf(request.path));
|
||||
return {
|
||||
data: {
|
||||
...body,
|
||||
availableLabs: body.availableLabs.filter(
|
||||
(m) => m.key !== "lab:bm-ferritin",
|
||||
),
|
||||
},
|
||||
};
|
||||
})
|
||||
.on("GET", "/api/timeline/readiness", {
|
||||
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
|
||||
})
|
||||
.on("GET", "/api/life-events", { data: { events: [] } });
|
||||
const { screen } = await renderWithApp(<TimelineView />, {
|
||||
user: {
|
||||
timezone: "Europe/Berlin",
|
||||
modules: { timeline: true, labs: true },
|
||||
},
|
||||
});
|
||||
await screen.getByRole("button", { name: /Values \(1\)/ }).click();
|
||||
const ferritin = screen.getByRole("menuitemcheckbox", { name: "Ferritin" });
|
||||
await expect.element(ferritin).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
await ferritin.click();
|
||||
await expect.poll(() => lastTimelineValues(net)).toEqual([]);
|
||||
// Unticked, it is offered no more.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
document.querySelectorAll(
|
||||
'[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]',
|
||||
).length,
|
||||
)
|
||||
.toBe(0);
|
||||
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([]);
|
||||
});
|
||||
|
||||
it("neither offers nor asks for a marker with the labs module off", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net, screen } = await mount(["WEIGHT", "lab:bm-ferritin"], {
|
||||
timeline: true,
|
||||
labs: false,
|
||||
});
|
||||
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
|
||||
await expect.element(trigger).toBeVisible();
|
||||
expect(lastTimelineValues(net)).toEqual(["WEIGHT"]);
|
||||
await trigger.click();
|
||||
await expect
|
||||
.element(screen.getByRole("menuitemcheckbox", { name: "Weight" }))
|
||||
.toBeVisible();
|
||||
expect(document.querySelector('[data-slot="timeline-values-labs"]')).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a reading's point on its own by keyboard", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
await mount(["lab:bm-ferritin"]);
|
||||
const target = () =>
|
||||
document.querySelector<HTMLButtonElement>(
|
||||
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="r1"]',
|
||||
);
|
||||
await expect.poll(() => target() !== null).toBe(true);
|
||||
expect(target()!.getAttribute("aria-label")).toBe(
|
||||
"Ferritin, Feb 12, 2024: 18 ng/mL, below range, reference 30–400 ng/mL, Report: February report",
|
||||
);
|
||||
// One tab stop per line, on its newest reading; Left walks back.
|
||||
target()!
|
||||
.parentElement?.querySelector<HTMLButtonElement>('[data-t="r2"]')
|
||||
?.focus();
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
expect(document.activeElement).toBe(target());
|
||||
const tip = document.querySelector('[data-slot="timeline-point-tip"]');
|
||||
expect(tip?.textContent).toContain("18 ng/mL");
|
||||
expect(tip?.textContent).toContain("below range");
|
||||
expect(tip?.textContent).toContain("February report");
|
||||
});
|
||||
|
||||
it("fits a phone: no sideways scroll, a named values button, the readings on the lab day", async () => {
|
||||
await page.viewport(390, 844);
|
||||
const { screen } = await mount(["lab:bm-ferritin"]);
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Values (1)" }))
|
||||
.toBeVisible();
|
||||
// Newest first, each lab day with its own readings.
|
||||
await expect
|
||||
.poll(() =>
|
||||
[
|
||||
...document.querySelectorAll('[data-slot="timeline-chronicle-labs"]'),
|
||||
].map((el) => el.textContent),
|
||||
)
|
||||
.toEqual(["Ferritin 120 ng/mL", "Ferritin 18 ng/mL, below range"]);
|
||||
const root = document.documentElement;
|
||||
expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth);
|
||||
const mobile = document.querySelector<HTMLElement>(
|
||||
'[data-slot="timeline-mobile"]',
|
||||
)!;
|
||||
expect(mobile.scrollWidth).toBeLessThanOrEqual(mobile.clientWidth);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lab-marker cap on the timeline page", () => {
|
||||
const id = (i: number) => `m${String(i).padStart(2, "0")}`;
|
||||
const marker = (i: number) => ({
|
||||
key: `lab:${id(i)}`,
|
||||
biomarkerId: id(i),
|
||||
name: `Marker ${String(i).padStart(2, "0")}`,
|
||||
panel: null,
|
||||
unit: "U/L",
|
||||
});
|
||||
|
||||
/** A record with 26 markers, a stored choice naming 30 (four deleted). */
|
||||
async function mountMany() {
|
||||
const stored = Array.from({ length: 30 }, (_, i) => `lab:${id(i)}`);
|
||||
window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
|
||||
setSearch("?zoom=all");
|
||||
const net = stubFetch()
|
||||
.on("GET", "/api/timeline", () => ({
|
||||
data: {
|
||||
...timeline([]),
|
||||
availableLabs: Array.from({ length: 26 }, (_, i) => marker(i)),
|
||||
},
|
||||
}))
|
||||
.on("GET", "/api/timeline/readiness", {
|
||||
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
|
||||
})
|
||||
.on("GET", "/api/life-events", { data: { events: [] } });
|
||||
const { screen } = await renderWithApp(<TimelineView />, {
|
||||
user: {
|
||||
timezone: "Europe/Berlin",
|
||||
modules: { timeline: true, labs: true },
|
||||
},
|
||||
});
|
||||
return { net, screen };
|
||||
}
|
||||
|
||||
it("asks for no more markers than the server takes, and holds further ones back until one is removed", async () => {
|
||||
await page.viewport(1280, 900);
|
||||
const { net, screen } = await mountMany();
|
||||
const first24 = Array.from({ length: 24 }, (_, i) => `lab:${id(i)}`);
|
||||
await expect.poll(() => lastTimelineValues(net)).toEqual(first24);
|
||||
|
||||
await screen.getByRole("button", { name: /Values \(24\)/ }).click();
|
||||
const extra = screen.getByRole("menuitemcheckbox", { name: "Marker 24" });
|
||||
await expect.element(extra).toHaveAttribute("aria-checked", "false");
|
||||
await expect.element(extra).toHaveAttribute("aria-disabled", "true");
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText("Up to 24 lab values at once", { exact: false }),
|
||||
)
|
||||
.toBeVisible();
|
||||
// A chosen marker can always be removed.
|
||||
const chosen = screen.getByRole("menuitemcheckbox", { name: "Marker 00" });
|
||||
expect(chosen.element().getAttribute("aria-disabled")).not.toBe("true");
|
||||
|
||||
await chosen.click();
|
||||
await expect.element(chosen).toHaveAttribute("aria-checked", "false");
|
||||
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual(
|
||||
first24.slice(1),
|
||||
);
|
||||
await expect.element(extra).not.toHaveAttribute("aria-disabled", "true");
|
||||
expect(
|
||||
document.querySelector('[data-slot="timeline-values-labs-full"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -24,13 +24,10 @@ import { SERIES_PALETTE, assignSeriesColors } from "../series-colors";
|
||||
import type { SeriesValueFormat } from "../series-format";
|
||||
import { SeriesLines } from "../timeline-chart";
|
||||
import { TimelineChronicle } from "../timeline-chronicle";
|
||||
import {
|
||||
SERIES_GAP,
|
||||
SERIES_HEIGHT,
|
||||
layoutTimeline,
|
||||
} from "../timeline-geometry";
|
||||
import { SERIES_GAP, layoutTimeline } from "../timeline-geometry";
|
||||
import { SERIES_HEIGHT } from "../series-geometry";
|
||||
import { ValueSeriesMenu } from "../timeline-menus";
|
||||
import { TimelineLegend } from "../timeline-view";
|
||||
import { TimelineLegend } from "../timeline-legend";
|
||||
import { useSeriesValueFormat } from "../use-series-value-format";
|
||||
import { item, wordsIn } from "./timeline-fixture";
|
||||
|
||||
@@ -150,6 +147,8 @@ function twoMedications(): TimelineResponse {
|
||||
standing: [],
|
||||
series: [],
|
||||
availableSeries: [],
|
||||
labSeries: [],
|
||||
availableLabs: [],
|
||||
notable: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Lab markers on the timeline (#1299): their rows under the value lines,
|
||||
* their readings in the selection bar and the phone chronicle, and their
|
||||
* table for a screen reader.
|
||||
*
|
||||
* A lab line is its readings, one point each on the day of the draw, never a
|
||||
* mean. Each row has its own scale, which holds the reference range as well
|
||||
* as the points, and the range is painted behind them as the labs page
|
||||
* paints it: a faint band with dashed edges, in the row's own colour. A
|
||||
* reading outside its range is a triangle pointing the way it left (up above
|
||||
* the range, down below it) and says so in words wherever it is written out,
|
||||
* so the colour is never the only sign. The numbers read through the labs
|
||||
* page's own formatter, so a reading keeps every digit it was stored with.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useLabNumber } from "@/components/labs/use-lab-format";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import type {
|
||||
TimelineBucket,
|
||||
TimelineLabPoint,
|
||||
TimelineLabSeries,
|
||||
} from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { ChronicleEntry } from "./chronicle-model";
|
||||
import { TIMELINE_LAB_RANGE_KEY } from "./label-keys";
|
||||
import {
|
||||
bucketAfter,
|
||||
dayKey,
|
||||
dayNumber,
|
||||
formatDayMonth,
|
||||
formatDayMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
|
||||
|
||||
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
|
||||
export type LabValueText = (
|
||||
value: number,
|
||||
sourceValue: number | null,
|
||||
unit: string,
|
||||
) => string;
|
||||
|
||||
export function useLabValueText(): LabValueText {
|
||||
const labNumber = useLabNumber();
|
||||
return useCallback(
|
||||
(value, sourceValue, unit) =>
|
||||
`${labNumber(value, sourceValue)} ${unit}`.trim(),
|
||||
[labNumber],
|
||||
);
|
||||
}
|
||||
|
||||
/** "below range" / "above range", or null for a reading in range. */
|
||||
export function useLabVerdict(): (
|
||||
status: TimelineLabPoint["rangeStatus"],
|
||||
) => string | null {
|
||||
const { t } = useTranslations();
|
||||
return useCallback(
|
||||
(status) =>
|
||||
status === "below" || status === "above"
|
||||
? t(TIMELINE_LAB_RANGE_KEY[status])
|
||||
: null,
|
||||
[t],
|
||||
);
|
||||
}
|
||||
|
||||
/** "Reference 30–400 ng/mL", or null without a range. */
|
||||
export function useLabReference(): (
|
||||
point: Pick<TimelineLabPoint, "referenceLow" | "referenceHigh">,
|
||||
unit: string,
|
||||
) => string | null {
|
||||
const { t } = useTranslations();
|
||||
const labNumber = useLabNumber();
|
||||
return useCallback(
|
||||
(point, unit) => {
|
||||
const range = formatReferenceRange(
|
||||
point.referenceLow,
|
||||
point.referenceHigh,
|
||||
(v) => labNumber(v),
|
||||
);
|
||||
return range
|
||||
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
|
||||
: null;
|
||||
},
|
||||
[t, labNumber],
|
||||
);
|
||||
}
|
||||
|
||||
/** The readings of every lab line whose day lies in `[from, to]`. */
|
||||
export function labReadingsIn(
|
||||
labSeries: readonly TimelineLabSeries[],
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{ key: string; unit: string; point: TimelineLabPoint }> {
|
||||
return labSeries.flatMap((s) =>
|
||||
s.points
|
||||
.filter((p) => p.date >= from && p.date <= to)
|
||||
.map((point) => ({ key: s.key, unit: s.unit, point })),
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── The rows on the chart ───────────────────────────────────────────────── */
|
||||
|
||||
/** Where a row's name and latest value start, as on the value lines. */
|
||||
const TEXT_X = 16;
|
||||
|
||||
/**
|
||||
* A reading's mark: a dot in range (or without one), a triangle pointing up
|
||||
* above it and down below it. Every mark takes the row's `currentColor`.
|
||||
*/
|
||||
export function LabPointGlyph({
|
||||
x,
|
||||
y,
|
||||
status,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
status: TimelineLabPoint["rangeStatus"];
|
||||
}) {
|
||||
if (status === "above" || status === "below") {
|
||||
const d =
|
||||
status === "above"
|
||||
? `M${x} ${y - 4.5}L${x + 4.5} ${y + 3.5}L${x - 4.5} ${y + 3.5}Z`
|
||||
: `M${x} ${y + 4.5}L${x + 4.5} ${y - 3.5}L${x - 4.5} ${y - 3.5}Z`;
|
||||
return (
|
||||
<path
|
||||
d={d}
|
||||
data-slot="timeline-lab-point"
|
||||
data-status={status}
|
||||
fill="currentColor"
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
data-slot="timeline-lab-point"
|
||||
data-status={status}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={3.25}
|
||||
fill="currentColor"
|
||||
stroke="var(--card)"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabLines({
|
||||
layout,
|
||||
selectedBucket,
|
||||
bucket,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
valueText,
|
||||
}: {
|
||||
layout: Pick<
|
||||
TimelineLayout,
|
||||
"labSeries" | "series" | "seriesTop" | "scale" | "width"
|
||||
>;
|
||||
/** The first day of the selected bucket: its readings are ringed. */
|
||||
selectedBucket: string | null;
|
||||
bucket: TimelineBucket;
|
||||
seriesLabel: (key: string) => string;
|
||||
seriesColor: (key: string) => string;
|
||||
valueText: LabValueText;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { scale, width } = layout;
|
||||
const rows = layout.labSeries;
|
||||
const selectedTo = selectedBucket
|
||||
? dayKey(dayNumber(bucketAfter(selectedBucket, bucket)) - 1)
|
||||
: null;
|
||||
const nameLines = (key: string) =>
|
||||
seriesNameLines(seriesLabel(key), scale.x0 - 12 - TEXT_X, 13);
|
||||
return (
|
||||
<>
|
||||
{/* The hairline above the rows, when no value line drew it already. */}
|
||||
{layout.series.length === 0 && rows.length > 0 && (
|
||||
<line
|
||||
x1={0}
|
||||
x2={width}
|
||||
y1={layout.seriesTop - 18}
|
||||
y2={layout.seriesTop - 18}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
)}
|
||||
{rows.map((row) => {
|
||||
const color = seriesColor(row.key);
|
||||
const lines = nameLines(row.key);
|
||||
return (
|
||||
<g
|
||||
key={row.key}
|
||||
data-lab-series={row.key}
|
||||
data-color={color}
|
||||
color={color}
|
||||
>
|
||||
{row.band && (
|
||||
<g data-slot="timeline-lab-band">
|
||||
{row.band.top !== null && row.band.bottom !== null && (
|
||||
<rect
|
||||
x={scale.x0}
|
||||
y={row.band.top}
|
||||
width={Math.max(0, scale.x1 - scale.x0)}
|
||||
height={Math.max(0, row.band.bottom - row.band.top)}
|
||||
fill="currentColor"
|
||||
fillOpacity={0.1}
|
||||
data-slot="timeline-lab-band-fill"
|
||||
/>
|
||||
)}
|
||||
{[row.band.top, row.band.bottom].map((edge, i) =>
|
||||
edge === null ? null : (
|
||||
<line
|
||||
key={i}
|
||||
data-slot="timeline-lab-band-edge"
|
||||
x1={scale.x0}
|
||||
x2={scale.x1}
|
||||
y1={edge}
|
||||
y2={edge}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.4}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
<line
|
||||
data-slot="timeline-series-swatch"
|
||||
x1={1.5}
|
||||
x2={10.5}
|
||||
y1={row.top + 8}
|
||||
y2={row.top + 8}
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
data-slot="timeline-series-name"
|
||||
x={TEXT_X}
|
||||
y={row.top + 12}
|
||||
fontSize={13}
|
||||
fontWeight={500}
|
||||
fill="var(--foreground)"
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<tspan key={i} x={TEXT_X} dy={i === 0 ? 0 : 15}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
<text
|
||||
x={TEXT_X}
|
||||
y={row.top + 30 + (lines.length - 1) * 15}
|
||||
fontSize={12}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{row.latest === null
|
||||
? t("timeline.values.noneInWindow")
|
||||
: valueText(row.latest.value, row.latest.sourceValue, row.unit)}
|
||||
</text>
|
||||
{row.path && (
|
||||
<path
|
||||
d={row.path}
|
||||
data-slot="timeline-lab-line"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeOpacity={0.6}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{row.points.map((point) => (
|
||||
<g
|
||||
key={point.id}
|
||||
data-slot="timeline-lab-mark"
|
||||
data-id={point.id}
|
||||
data-date={point.date}
|
||||
>
|
||||
<LabPointGlyph
|
||||
x={point.x}
|
||||
y={point.y}
|
||||
status={point.rangeStatus}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
{selectedBucket !== null &&
|
||||
selectedTo !== null &&
|
||||
row.points
|
||||
.filter(
|
||||
(point) =>
|
||||
point.date >= selectedBucket && point.date <= selectedTo,
|
||||
)
|
||||
.map((point) => (
|
||||
<circle
|
||||
key={`selected-${point.id}`}
|
||||
data-slot="timeline-series-selected"
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={6}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Written out ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Lab readings as a line of their own under the means: "● Ferritin 18 ng/mL,
|
||||
* below range, 12 Feb." with the report it came from as a link. Each reading
|
||||
* is one unit that wraps only between units, like a means line.
|
||||
*/
|
||||
export function LabReadingsLine({
|
||||
readings,
|
||||
label,
|
||||
seriesColor,
|
||||
valueText,
|
||||
showDate,
|
||||
links = true,
|
||||
intl,
|
||||
}: {
|
||||
readings: ReturnType<typeof labReadingsIn>;
|
||||
label: (key: string) => string;
|
||||
seriesColor: (key: string) => string;
|
||||
valueText: LabValueText;
|
||||
/** The day beside each reading: where a line covers more than one day. */
|
||||
showDate: boolean;
|
||||
/**
|
||||
* The report as a link. Off inside a row that is itself a button (a link
|
||||
* cannot sit inside one); the day the row opens names the report.
|
||||
*/
|
||||
links?: boolean;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const verdict = useLabVerdict();
|
||||
return readings.map(({ key, unit, point }, i) => {
|
||||
const words = [
|
||||
`${label(key)} ${valueText(point.value, point.sourceValue, unit)}`,
|
||||
verdict(point.rangeStatus),
|
||||
showDate ? formatDayMonth(point.date, intl) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return (
|
||||
<span
|
||||
key={`${key}:${point.id}`}
|
||||
data-slot="timeline-lab-reading"
|
||||
data-series={key}
|
||||
data-status={point.rangeStatus}
|
||||
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{i > 0 ? <span className="sr-only">, </span> : null}
|
||||
<span
|
||||
data-slot="timeline-series-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: seriesColor(key) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate">{words}</span>
|
||||
{links && point.report ? (
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link
|
||||
href={point.report.href}
|
||||
data-slot="timeline-lab-report"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{point.report.title
|
||||
? t("timeline.labs.report", { title: point.report.title })
|
||||
: t("timeline.labs.reportUntitled")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A lab day's row in the phone chronicle lists the chosen lab lines'
|
||||
* readings of that day under its names. The row is a button that opens the
|
||||
* day, so the report is named there, not linked here.
|
||||
*/
|
||||
export function ChronicleLabReadings({
|
||||
entry,
|
||||
labSeries,
|
||||
seriesColor,
|
||||
className,
|
||||
}: {
|
||||
entry: ChronicleEntry;
|
||||
labSeries: readonly TimelineLabSeries[];
|
||||
seriesColor: (key: string) => string;
|
||||
/** The row's means-line classes, so both read alike. */
|
||||
className: string;
|
||||
}) {
|
||||
const { locale } = useTranslations();
|
||||
const valueText = useLabValueText();
|
||||
if (
|
||||
entry.kind !== "item" ||
|
||||
entry.item.kind !== "labDay" ||
|
||||
entry.role === "end"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const readings = labReadingsIn(labSeries, entry.date, entry.date);
|
||||
if (readings.length === 0) return null;
|
||||
return (
|
||||
<span className={className} data-slot="timeline-chronicle-labs">
|
||||
<LabReadingsLine
|
||||
readings={readings}
|
||||
label={(key) => labSeries.find((s) => s.key === key)?.name ?? key}
|
||||
seriesColor={seriesColor}
|
||||
valueText={valueText}
|
||||
showDate={false}
|
||||
links={false}
|
||||
intl={resolveIntlLocale(locale)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lab line with readings left out because their unit does not convert into
|
||||
* the marker's says so, once, under the chart (and over the phone list):
|
||||
* nothing is dropped without a word.
|
||||
*/
|
||||
export function LabExcludedNote({
|
||||
labSeries,
|
||||
label,
|
||||
block = false,
|
||||
}: {
|
||||
labSeries: readonly TimelineLabSeries[];
|
||||
label: (key: string) => string;
|
||||
/** A line of its own (the phone list), not runs inside the legend. */
|
||||
block?: boolean;
|
||||
}) {
|
||||
const { tCount } = useTranslations();
|
||||
const lines = labSeries.filter((s) => s.excluded > 0);
|
||||
if (lines.length === 0) return null;
|
||||
const notes = lines.map((s) => (
|
||||
<span
|
||||
key={s.key}
|
||||
data-slot="timeline-lab-excluded"
|
||||
data-series={s.key}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
{tCount("timeline.labs.excluded", s.excluded, { name: label(s.key) })}
|
||||
</span>
|
||||
));
|
||||
return block ? (
|
||||
<div
|
||||
className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs"
|
||||
data-slot="timeline-mobile-lab-note"
|
||||
>
|
||||
{notes}
|
||||
</div>
|
||||
) : (
|
||||
notes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lab lines as a table, for a screen reader: one row per reading, its
|
||||
* marker, day, value, verdict, range and report. Visually hidden by a
|
||||
* wrapper, as the other chart tables are.
|
||||
*/
|
||||
export function LabSeriesTable({
|
||||
labSeries,
|
||||
window,
|
||||
label,
|
||||
valueText,
|
||||
intl,
|
||||
}: {
|
||||
labSeries: readonly TimelineLabSeries[];
|
||||
window: { from: string; to: string };
|
||||
label: (key: string) => string;
|
||||
valueText: LabValueText;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const verdict = useLabVerdict();
|
||||
const reference = useLabReference();
|
||||
const rows = labReadingsIn(labSeries, window.from, window.to).sort((a, b) =>
|
||||
a.point.date < b.point.date ? 1 : -1,
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="sr-only" data-slot="timeline-lab-table-frame">
|
||||
<table data-slot="timeline-lab-table">
|
||||
<caption>{t("timeline.table.labsCaption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.marker")}</th>
|
||||
<th scope="col">{t("timeline.table.date")}</th>
|
||||
<th scope="col">{t("timeline.table.value")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ key, unit, point }) => (
|
||||
<tr key={point.id}>
|
||||
<th scope="row">{label(key)}</th>
|
||||
<td>{formatDayMonthYear(point.date, intl)}</td>
|
||||
<td>
|
||||
{[
|
||||
valueText(point.value, point.sourceValue, unit),
|
||||
verdict(point.rangeStatus),
|
||||
reference(point, unit),
|
||||
point.report
|
||||
? point.report.title
|
||||
? t("timeline.labs.report", {
|
||||
title: point.report.title,
|
||||
})
|
||||
: t("timeline.labs.reportUntitled")
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,17 @@ export const TIMELINE_LEGEND_MEAN_KEY: Readonly<
|
||||
day: "timeline.legendMeanDay",
|
||||
};
|
||||
|
||||
/**
|
||||
* A lab reading outside its reference range, worded beside the value
|
||||
* (#1299). In range and unknown read as the value alone.
|
||||
*/
|
||||
export const TIMELINE_LAB_RANGE_KEY: Readonly<
|
||||
Record<"below" | "above", string>
|
||||
> = {
|
||||
below: "timeline.labs.below",
|
||||
above: "timeline.labs.above",
|
||||
};
|
||||
|
||||
/** The two blood pressure lines read as their own names on the chart. */
|
||||
export const TIMELINE_BLOOD_PRESSURE_SERIES_KEY: Readonly<
|
||||
Record<"BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", string>
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
* above it, not in a box of its own. An entry's parts are joined by commas,
|
||||
* a period still open reads "since 3 Mar." (it needs no "ongoing"), and a
|
||||
* medication is one entry however many of its items touch the bucket
|
||||
* (`medicationPeriodItem`).
|
||||
* (`medicationPeriodItem`). The chosen lab lines' readings in the bucket
|
||||
* follow the means, each as measured, with its day where the bucket is
|
||||
* longer than one, a word where it lies outside its range, and a link to the
|
||||
* report it was read from (#1299).
|
||||
*/
|
||||
import type { TimelineResponse } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
@@ -38,6 +41,7 @@ import {
|
||||
formatMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { useItemWords } from "./item-words";
|
||||
import { LabReadingsLine, labReadingsIn, useLabValueText } from "./lab-lines";
|
||||
import { itemsInPeriod } from "./timeline-geometry";
|
||||
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
|
||||
import {
|
||||
@@ -82,9 +86,12 @@ export function SelectionBar({
|
||||
const periodTo = dayKey(dayNumber(bucketAfter(periodFrom, bucket)) - 1);
|
||||
const entries = itemsInPeriod(timeline.lanes, periodFrom, periodTo, today);
|
||||
const means = bucketValues(timeline.series, timeline.bucket, selected);
|
||||
const labReadings = labReadingsIn(timeline.labSeries, periodFrom, periodTo);
|
||||
const labValueText = useLabValueText();
|
||||
// A bucket with a value but no entry is not empty: the means below say
|
||||
// what it holds, so "No entries" is kept for a stretch with nothing.
|
||||
const hasValue = means.values.some((v) => v.mean !== null);
|
||||
const hasValue =
|
||||
means.values.some((v) => v.mean !== null) || labReadings.length > 0;
|
||||
// "Ø {values}": the words around the values, so the values can carry
|
||||
// their colour dots.
|
||||
const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", {
|
||||
@@ -218,6 +225,18 @@ export function SelectionBar({
|
||||
{meanAfter ? <span>{meanAfter}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
{labReadings.length > 0 && (
|
||||
<span className={MEAN_PARTS_ROW} data-slot="timeline-selection-labs">
|
||||
<LabReadingsLine
|
||||
readings={labReadings}
|
||||
label={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
valueText={labValueText}
|
||||
showDate={bucket !== "day"}
|
||||
intl={intl}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* The value lines and lab lines under the timeline's lanes, as pure
|
||||
* geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks
|
||||
* them under the lanes; each row is laid out here on its own scale.
|
||||
*/
|
||||
import type {
|
||||
TimelineBucket,
|
||||
TimelineLabPoint,
|
||||
TimelineLabSeries,
|
||||
TimelineSeries,
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates";
|
||||
import type { Scale, TimeWindow } from "./timeline-geometry";
|
||||
|
||||
/** Height of one value line, label included. */
|
||||
export const SERIES_HEIGHT = 60;
|
||||
|
||||
/* ─── Value lines ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SeriesPointLayout {
|
||||
/** The bucket's first day. */
|
||||
t: string;
|
||||
mean: number;
|
||||
/** Readings behind the mean. */
|
||||
count: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
|
||||
thin: boolean;
|
||||
}
|
||||
|
||||
export interface SeriesLayout {
|
||||
key: string;
|
||||
unit: string | null;
|
||||
top: number;
|
||||
height: number;
|
||||
/** SVG path data of the solid runs; empty when no two points touch. */
|
||||
path: string;
|
||||
/** SVG path data of the dashed bridges over short gaps; may be empty. */
|
||||
bridges: string;
|
||||
/** The most recent mean inside the window, or null. */
|
||||
latest: number | null;
|
||||
points: SeriesPointLayout[];
|
||||
}
|
||||
|
||||
/** A bucket mean from fewer readings than this is drawn hollow. */
|
||||
export const THIN_BUCKET_COUNT = 3;
|
||||
|
||||
/**
|
||||
* The most missing buckets a dashed bridge spans. Beyond it the line stops
|
||||
* and starts again, so a long silence reads as one and not as a trend.
|
||||
*
|
||||
* The rule is one length, about six months between the two points a bridge
|
||||
* joins, never more, and never more than a third of what the zoom shows:
|
||||
* one missing quarter (the points are six months apart), two missing months
|
||||
* in a year (three months apart, a sixth of the window) and four missing
|
||||
* weeks in three months (five weeks apart, the window's third). A longer
|
||||
* bridge would draw a slope through a stretch where nothing was measured.
|
||||
*/
|
||||
export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
|
||||
quarter: 1,
|
||||
month: 2,
|
||||
week: 4,
|
||||
// Days come only with a chosen range under six weeks. Three missing days
|
||||
// (points four days apart) bridge a weekend away or a forgotten cuff;
|
||||
// a longer silence in a window that short is a break worth seeing, and
|
||||
// the bridge stays well under a third of even a two-week range.
|
||||
day: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* One neutral line per series, scaled to its own range inside the window.
|
||||
* A point sits in the middle of its bucket, and every bucket with a reading
|
||||
* gets its point, so one month with no neighbours stays visible. Missing
|
||||
* buckets stay missing: a short gap is bridged by a dashed stroke
|
||||
* ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
|
||||
* between them, a longer one breaks the line.
|
||||
*/
|
||||
export function layoutSeries(
|
||||
series: readonly TimelineSeries[],
|
||||
top: number,
|
||||
window: TimeWindow,
|
||||
scale: Scale,
|
||||
bucket: TimelineBucket,
|
||||
): SeriesLayout[] {
|
||||
return series.map((s, index) => {
|
||||
const rowTop = top + index * SERIES_HEIGHT + 6;
|
||||
const height = SERIES_HEIGHT - 16;
|
||||
// A bucket that starts before the window still belongs to it while any
|
||||
// of its days lie inside.
|
||||
const inside = s.points
|
||||
.filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
|
||||
.sort((a, b) => (a.t < b.t ? -1 : 1));
|
||||
if (inside.length === 0) {
|
||||
return {
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
top: rowTop,
|
||||
height,
|
||||
path: "",
|
||||
bridges: "",
|
||||
latest: null,
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
let lo = Math.min(...inside.map((p) => p.mean));
|
||||
let hi = Math.max(...inside.map((p) => p.mean));
|
||||
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
|
||||
const points = inside.map((p) => {
|
||||
const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
|
||||
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
|
||||
return {
|
||||
t: p.t,
|
||||
mean: p.mean,
|
||||
count: p.count,
|
||||
x,
|
||||
y: y(p.mean),
|
||||
thin: p.count < THIN_BUCKET_COUNT,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
|
||||
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
|
||||
let path = "";
|
||||
let bridges = "";
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
const missing =
|
||||
bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
|
||||
if (missing === 0) {
|
||||
// Continue the run, or open a new one at the previous point.
|
||||
path += path.endsWith(xy(prev))
|
||||
? `L${xy(cur)}`
|
||||
: `M${xy(prev)}L${xy(cur)}`;
|
||||
} else if (missing <= MAX_BRIDGED_GAP[bucket]) {
|
||||
bridges += `M${xy(prev)}L${xy(cur)}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
top: rowTop,
|
||||
height,
|
||||
path,
|
||||
bridges,
|
||||
latest: inside[inside.length - 1].mean,
|
||||
points,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Lab lines ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface LabPointLayout extends TimelineLabPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface LabSeriesLayout {
|
||||
key: string;
|
||||
biomarkerId: string;
|
||||
unit: string;
|
||||
top: number;
|
||||
height: number;
|
||||
/** SVG path data joining the points in time order; empty under two. */
|
||||
path: string;
|
||||
/**
|
||||
* The reference range as y positions inside the row: `top` / `bottom` of
|
||||
* the band, null on a side the range leaves open.
|
||||
*/
|
||||
band: { top: number | null; bottom: number | null } | null;
|
||||
/** The newest point inside the window, or null. */
|
||||
latest: LabPointLayout | null;
|
||||
points: LabPointLayout[];
|
||||
excluded: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per lab marker, below the value lines and in their rhythm, each on
|
||||
* its own scale. A lab line is its readings: a point per reading on the day
|
||||
* of the draw, joined in time order by a thin stroke, never a mean and never
|
||||
* a value between readings (#1299). The scale holds the points and the
|
||||
* reference range together, as the labs page's chart does, so the band
|
||||
* behind the points is always in view and a point outside it reads as
|
||||
* outside at a glance.
|
||||
*/
|
||||
export function layoutLabSeries(
|
||||
series: readonly TimelineLabSeries[],
|
||||
top: number,
|
||||
window: TimeWindow,
|
||||
scale: Scale,
|
||||
): LabSeriesLayout[] {
|
||||
return series.map((s, index) => {
|
||||
const rowTop = top + index * SERIES_HEIGHT + 6;
|
||||
const height = SERIES_HEIGHT - 16;
|
||||
const inside = s.points.filter(
|
||||
(p) => p.date >= window.from && p.date <= window.to,
|
||||
);
|
||||
const bounds = [s.band?.low, s.band?.high].filter(
|
||||
(b): b is number => typeof b === "number",
|
||||
);
|
||||
const span = [...inside.map((p) => p.value), ...bounds];
|
||||
let lo = span.length > 0 ? Math.min(...span) : 0;
|
||||
let hi = span.length > 0 ? Math.max(...span) : 1;
|
||||
// A lone value, or a range of one number, still needs a span to sit in.
|
||||
const pad = hi > lo ? (hi - lo) * 0.12 : Math.abs(hi) * 0.1 || 1;
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
|
||||
const points = inside.map((p) => ({
|
||||
...p,
|
||||
x: Math.min(scale.x1, Math.max(scale.x0, scale.xMid(p.date))),
|
||||
y: y(p.value),
|
||||
}));
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
|
||||
const xy = (p: LabPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
|
||||
const path =
|
||||
points.length > 1
|
||||
? points.map((p, i) => `${i === 0 ? "M" : "L"}${xy(p)}`).join("")
|
||||
: "";
|
||||
const band =
|
||||
s.band && (s.band.low !== null || s.band.high !== null)
|
||||
? {
|
||||
top: s.band.high === null ? null : y(s.band.high),
|
||||
bottom: s.band.low === null ? null : y(s.band.low),
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
key: s.key,
|
||||
biomarkerId: s.biomarkerId,
|
||||
unit: s.unit,
|
||||
top: rowTop,
|
||||
height,
|
||||
path,
|
||||
band,
|
||||
latest: points.at(-1) ?? null,
|
||||
points,
|
||||
excluded: s.excluded,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,11 @@
|
||||
*
|
||||
* The card is placed above the point and kept inside the chart's width
|
||||
* (`tipLeft`), so it never adds a horizontal scroll of its own.
|
||||
*
|
||||
* A lab line's point (#1299) is one reading, not a mean: its card names the
|
||||
* day of the draw, the value, whether it lies outside its reference range
|
||||
* and the range itself, and the report it was read from. A tap pins the card
|
||||
* with a link to that report beside "View the whole day".
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
@@ -30,13 +35,22 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { RichChartTooltip } from "@/components/charts/chart-tooltip";
|
||||
import {
|
||||
RichChartTooltip,
|
||||
type RichTooltipRow,
|
||||
} from "@/components/charts/chart-tooltip";
|
||||
import { TooltipDayAction } from "@/components/day/chart-day";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { useLabReference, useLabValueText, useLabVerdict } from "./lab-lines";
|
||||
import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
|
||||
import { formatDayMonthYear } from "./timeline-dates";
|
||||
import type { TimelineLayout } from "./timeline-geometry";
|
||||
|
||||
/** Hit target across, in px: bigger than the dot, a comfortable aim. */
|
||||
@@ -61,6 +75,21 @@ interface Active {
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
/** One point's target and card, a value line's or a lab line's alike. */
|
||||
interface TipPoint {
|
||||
/** The point's id within its line: a bucket start, or a reading's id. */
|
||||
t: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** The day a second tap, Enter or a double click opens. */
|
||||
day: string;
|
||||
ariaLabel: string;
|
||||
label: string;
|
||||
row: RichTooltipRow;
|
||||
/** Under the row: the report a reading came from. */
|
||||
report: { href: string; text: string } | null;
|
||||
}
|
||||
|
||||
export function SeriesPointTips({
|
||||
layout,
|
||||
today,
|
||||
@@ -81,7 +110,6 @@ export function SeriesPointTips({
|
||||
onSelect: (date: string) => void;
|
||||
onOpenDay: (date: string) => void;
|
||||
}) {
|
||||
const { tCount } = useTranslations();
|
||||
const [active, setActive] = useState<Active | null>(null);
|
||||
// Per line, the point that holds its one tab stop.
|
||||
const [stops, setStops] = useState<Record<string, string>>({});
|
||||
@@ -115,21 +143,24 @@ export function SeriesPointTips({
|
||||
return () => document.removeEventListener("pointerdown", away);
|
||||
}, [pinned]);
|
||||
|
||||
// The first day of a point's stretch that has happened, the day it opens.
|
||||
const dayOf = (t: string) => (t > today ? today : t);
|
||||
const lines = useTipLines({
|
||||
layout,
|
||||
today,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
fmt,
|
||||
bucketLabel,
|
||||
});
|
||||
|
||||
const series = layout.series.find((s) => s.key === active?.key);
|
||||
const point = series?.points.find((p) => p.t === active?.t);
|
||||
|
||||
const words = (key: string, t: string, mean: number, unit: string | null) =>
|
||||
`${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`;
|
||||
const line = lines.find((l) => l.key === active?.key);
|
||||
const point = line?.points.find((p) => p.t === active?.t);
|
||||
|
||||
function move(
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
key: string,
|
||||
index: number,
|
||||
) {
|
||||
const points = layout.series.find((s) => s.key === key)?.points ?? [];
|
||||
const points = lines.find((l) => l.key === key)?.points ?? [];
|
||||
let next: number | null = null;
|
||||
if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
|
||||
else if (event.key === "ArrowRight")
|
||||
@@ -149,7 +180,7 @@ export function SeriesPointTips({
|
||||
|
||||
return (
|
||||
<div data-slot="timeline-point-tips">
|
||||
{layout.series.map((s) =>
|
||||
{lines.map((s) =>
|
||||
s.points.map((p, i) => {
|
||||
const id = `${s.key}:${p.t}`;
|
||||
const stop = stops[s.key] ?? s.points.at(-1)?.t;
|
||||
@@ -165,7 +196,7 @@ export function SeriesPointTips({
|
||||
data-series={s.key}
|
||||
data-t={p.t}
|
||||
tabIndex={p.t === stop ? 0 : -1}
|
||||
aria-label={`${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`}
|
||||
aria-label={p.ariaLabel}
|
||||
onPointerDown={(event) => {
|
||||
pointerType.current = event.pointerType;
|
||||
event.stopPropagation();
|
||||
@@ -189,7 +220,7 @@ export function SeriesPointTips({
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpenDay(dayOf(p.t));
|
||||
onOpenDay(p.day);
|
||||
return;
|
||||
}
|
||||
move(event, s.key, i);
|
||||
@@ -197,7 +228,7 @@ export function SeriesPointTips({
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (pointerType.current === "mouse") {
|
||||
onSelect(dayOf(p.t));
|
||||
onSelect(p.day);
|
||||
return;
|
||||
}
|
||||
// A tap shows the card; a second tap on the same point opens
|
||||
@@ -207,14 +238,14 @@ export function SeriesPointTips({
|
||||
active.key === s.key &&
|
||||
active.t === p.t
|
||||
) {
|
||||
onOpenDay(dayOf(p.t));
|
||||
onOpenDay(p.day);
|
||||
return;
|
||||
}
|
||||
setActive({ key: s.key, t: p.t, pinned: true });
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenDay(dayOf(p.t));
|
||||
onOpenDay(p.day);
|
||||
}}
|
||||
className="focus-visible:ring-ring/50 absolute rounded-full outline-none focus-visible:ring-2"
|
||||
style={{
|
||||
@@ -227,11 +258,11 @@ export function SeriesPointTips({
|
||||
);
|
||||
}),
|
||||
)}
|
||||
{series && point ? (
|
||||
{line && point ? (
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-slot="timeline-point-tip"
|
||||
data-series={series.key}
|
||||
data-series={line.key}
|
||||
data-t={point.t}
|
||||
className={
|
||||
active?.pinned
|
||||
@@ -248,28 +279,141 @@ export function SeriesPointTips({
|
||||
>
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={bucketLabel(point.t)}
|
||||
rows={[
|
||||
{
|
||||
name: seriesLabel(series.key),
|
||||
value: formatSeriesValue(
|
||||
series.key,
|
||||
point.mean,
|
||||
series.unit,
|
||||
fmt,
|
||||
),
|
||||
color: seriesColor(series.key),
|
||||
delta: tCount("timeline.values.readings", point.count),
|
||||
},
|
||||
]}
|
||||
action={
|
||||
active?.pinned ? (
|
||||
<TooltipDayAction onOpen={() => onOpenDay(dayOf(point.t))} />
|
||||
) : undefined
|
||||
}
|
||||
label={point.label}
|
||||
rows={[point.row]}
|
||||
action={tipAction(point, active?.pinned ?? false, () =>
|
||||
onOpenDay(point.day),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Under a card's row: a reading's report (a link once the card is pinned, its
|
||||
* name while it only follows the pointer) and, pinned, the way into the day.
|
||||
*/
|
||||
function tipAction(
|
||||
point: TipPoint,
|
||||
pinned: boolean,
|
||||
openDay: () => void,
|
||||
): ReactNode {
|
||||
if (!point.report)
|
||||
return pinned ? <TooltipDayAction onOpen={openDay} /> : undefined;
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1.5" data-slot="timeline-point-tip-report">
|
||||
{pinned ? (
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link
|
||||
href={point.report.href}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{point.report.text}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{point.report.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pinned ? <TooltipDayAction onOpen={openDay} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every line's points as targets and cards, value lines first, then lab
|
||||
* lines, in the order they are drawn: a value line's point names its bucket
|
||||
* and the readings behind its mean, a lab line's point the day of its draw,
|
||||
* its verdict, its range and its report.
|
||||
*/
|
||||
function useTipLines({
|
||||
layout,
|
||||
today,
|
||||
seriesLabel,
|
||||
seriesColor,
|
||||
fmt,
|
||||
bucketLabel,
|
||||
}: {
|
||||
layout: TimelineLayout;
|
||||
today: string;
|
||||
seriesLabel: (key: string) => string;
|
||||
seriesColor: (key: string) => string;
|
||||
fmt: SeriesValueFormat;
|
||||
bucketLabel: (t: string) => string;
|
||||
}): Array<{ key: string; points: TipPoint[] }> {
|
||||
const { t: tr, tCount, locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const verdict = useLabVerdict();
|
||||
const labValueText = useLabValueText();
|
||||
const reference = useLabReference();
|
||||
// The first day of a point's stretch that has happened, the day it opens.
|
||||
const dayOf = (t: string) => (t > today ? today : t);
|
||||
|
||||
const words = (key: string, t: string, mean: number, unit: string | null) =>
|
||||
`${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`;
|
||||
|
||||
return [
|
||||
...layout.series.map((s) => ({
|
||||
key: s.key,
|
||||
points: s.points.map((p) => ({
|
||||
t: p.t,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
day: dayOf(p.t),
|
||||
ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`,
|
||||
label: bucketLabel(p.t),
|
||||
row: {
|
||||
name: seriesLabel(s.key),
|
||||
value: formatSeriesValue(s.key, p.mean, s.unit, fmt),
|
||||
color: seriesColor(s.key),
|
||||
delta: tCount("timeline.values.readings", p.count),
|
||||
},
|
||||
report: null,
|
||||
})),
|
||||
})),
|
||||
...layout.labSeries.map((s) => ({
|
||||
key: s.key,
|
||||
points: s.points.map((p) => {
|
||||
const value = labValueText(p.value, p.sourceValue, s.unit);
|
||||
const detail = [verdict(p.rangeStatus), reference(p, s.unit)]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const report = p.report
|
||||
? {
|
||||
href: p.report.href,
|
||||
text: p.report.title
|
||||
? tr("timeline.labs.report", { title: p.report.title })
|
||||
: tr("timeline.labs.reportUntitled"),
|
||||
}
|
||||
: null;
|
||||
const date = formatDayMonthYear(p.date, intl);
|
||||
return {
|
||||
t: p.id,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
day: dayOf(p.date),
|
||||
ariaLabel: [
|
||||
`${seriesLabel(s.key)}, ${date}: ${value}`,
|
||||
detail,
|
||||
report?.text,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
label: date,
|
||||
row: {
|
||||
name: seriesLabel(s.key),
|
||||
value,
|
||||
color: seriesColor(s.key),
|
||||
...(detail ? { delta: detail } : {}),
|
||||
},
|
||||
report,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -54,28 +54,29 @@ import type {
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { itemLine, useItemWords, type ItemWordsFn } from "./item-words";
|
||||
import { medicationListItems } from "./medication-rows";
|
||||
import { useItemWords, type ItemWordsFn } from "./item-words";
|
||||
import {
|
||||
LabLines,
|
||||
LabSeriesTable,
|
||||
useLabValueText,
|
||||
type LabValueText,
|
||||
} from "./lab-lines";
|
||||
import { SeriesTable, TimelineTable, bucketText } from "./timeline-tables";
|
||||
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
|
||||
import {
|
||||
bucketAfter,
|
||||
bucketStart,
|
||||
formatAtPrecision,
|
||||
formatBucket,
|
||||
formatMonthShort,
|
||||
formatMonthYear,
|
||||
dayNumber,
|
||||
} from "./timeline-dates";
|
||||
import {
|
||||
AXIS_HEIGHT,
|
||||
LANE_COLOR,
|
||||
dateAtPointer,
|
||||
estimateTextWidth,
|
||||
SERIES_HEIGHT,
|
||||
fitText,
|
||||
seriesNameLines,
|
||||
gridTicks,
|
||||
isSpan,
|
||||
todayLabelFits,
|
||||
wrapToWidth,
|
||||
layoutTimeline,
|
||||
@@ -85,6 +86,7 @@ import {
|
||||
type PlacedSpan,
|
||||
type TimeWindow,
|
||||
} from "./timeline-geometry";
|
||||
import { SERIES_HEIGHT } from "./series-geometry";
|
||||
import {
|
||||
formatSeriesValue,
|
||||
type MeanPart,
|
||||
@@ -157,6 +159,7 @@ export function TimelineChart({
|
||||
const { t, locale } = useTranslations();
|
||||
const words = useItemWords();
|
||||
const fmt = seriesFormat;
|
||||
const labValueText = useLabValueText();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
const { ref, width } = useElementWidth<HTMLDivElement>();
|
||||
|
||||
@@ -172,13 +175,24 @@ export function TimelineChart({
|
||||
window,
|
||||
lanes,
|
||||
series: timeline.series,
|
||||
labSeries: timeline.labSeries,
|
||||
bucket: timeline.bucket,
|
||||
words,
|
||||
startMissing: t("timeline.startUnknown"),
|
||||
today,
|
||||
})
|
||||
: null,
|
||||
[width, window, lanes, timeline.series, timeline.bucket, words, t, today],
|
||||
[
|
||||
width,
|
||||
window,
|
||||
lanes,
|
||||
timeline.series,
|
||||
timeline.labSeries,
|
||||
timeline.bucket,
|
||||
words,
|
||||
t,
|
||||
today,
|
||||
],
|
||||
);
|
||||
|
||||
const floor = timeline.range.dataFrom ?? window.from;
|
||||
@@ -266,6 +280,7 @@ export function TimelineChart({
|
||||
intl={intl}
|
||||
t={t}
|
||||
fmt={fmt}
|
||||
labValueText={labValueText}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
@@ -301,6 +316,13 @@ export function TimelineChart({
|
||||
fmt={fmt}
|
||||
intl={intl}
|
||||
/>
|
||||
<LabSeriesTable
|
||||
labSeries={timeline.labSeries}
|
||||
window={window}
|
||||
label={seriesLabel}
|
||||
valueText={labValueText}
|
||||
intl={intl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -320,10 +342,23 @@ export function SeriesNameLinks({
|
||||
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
|
||||
seriesLabel: (key: string) => string;
|
||||
}) {
|
||||
// A lab line's name leads to its marker's page on the labs list.
|
||||
const rows = [
|
||||
...layout.series.map((s) => ({
|
||||
key: s.key,
|
||||
top: s.top,
|
||||
href: metricPageHref(s.key),
|
||||
})),
|
||||
...layout.labSeries.map((s) => ({
|
||||
key: s.key,
|
||||
top: s.top,
|
||||
href: `/labs/${encodeURIComponent(s.biomarkerId)}`,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{layout.series.map((series) => {
|
||||
const href = metricPageHref(series.key);
|
||||
{rows.map((series) => {
|
||||
const href = series.href;
|
||||
if (!href) return null;
|
||||
return (
|
||||
<Link
|
||||
@@ -360,6 +395,7 @@ interface ChartBodyProps {
|
||||
intl: string;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
fmt: SeriesValueFormat;
|
||||
labValueText: LabValueText;
|
||||
}
|
||||
|
||||
function ChartBody({
|
||||
@@ -375,6 +411,7 @@ function ChartBody({
|
||||
intl,
|
||||
t,
|
||||
fmt,
|
||||
labValueText,
|
||||
}: ChartBodyProps) {
|
||||
const { scale, height, width } = layout;
|
||||
const todayInWindow = today >= window.from && today <= window.to;
|
||||
@@ -409,30 +446,12 @@ function ChartBody({
|
||||
</defs>
|
||||
|
||||
{noDataUntil !== null && (
|
||||
<g data-slot="timeline-no-data">
|
||||
<rect
|
||||
x={scale.x0}
|
||||
y={AXIS_HEIGHT}
|
||||
width={Math.max(0, noDataUntil - scale.x0)}
|
||||
height={height - AXIS_HEIGHT}
|
||||
fill="var(--muted)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
{wrapToWidth(
|
||||
t("timeline.noDataYet"),
|
||||
noDataUntil - scale.x0 - 12,
|
||||
).map((line, i, lines) => (
|
||||
<text
|
||||
key={line}
|
||||
x={scale.x0 + 8}
|
||||
y={height - 8 - (lines.length - 1 - i) * 13}
|
||||
fontSize={11}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<NoDataShade
|
||||
x0={scale.x0}
|
||||
until={noDataUntil}
|
||||
height={height}
|
||||
label={t("timeline.noDataYet")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Grid and axis labels. */}
|
||||
@@ -530,6 +549,16 @@ function ChartBody({
|
||||
fmt={fmt}
|
||||
/>
|
||||
|
||||
{/* Lab lines (#1299): single readings, under the value lines. */}
|
||||
<LabLines
|
||||
layout={layout}
|
||||
selectedBucket={selectedBucket}
|
||||
bucket={bucket}
|
||||
seriesLabel={seriesLabel}
|
||||
seriesColor={seriesColor}
|
||||
valueText={labValueText}
|
||||
/>
|
||||
|
||||
{/* Today. */}
|
||||
{todayInWindow && (
|
||||
<g data-slot="timeline-today">
|
||||
@@ -606,6 +635,46 @@ function ChartBody({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretch before the record's first entry, shaded and named, so an empty
|
||||
* start reads as "no data yet" and not as a quiet period.
|
||||
*/
|
||||
function NoDataShade({
|
||||
x0,
|
||||
until,
|
||||
height,
|
||||
label,
|
||||
}: {
|
||||
x0: number;
|
||||
until: number;
|
||||
height: number;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<g data-slot="timeline-no-data">
|
||||
<rect
|
||||
x={x0}
|
||||
y={AXIS_HEIGHT}
|
||||
width={Math.max(0, until - x0)}
|
||||
height={height - AXIS_HEIGHT}
|
||||
fill="var(--muted)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
{wrapToWidth(label, until - x0 - 12).map((line, i, lines) => (
|
||||
<text
|
||||
key={line}
|
||||
x={x0 + 8}
|
||||
y={height - 8 - (lines.length - 1 - i) * 13}
|
||||
fontSize={11}
|
||||
fill="var(--muted-foreground)"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value lines under the lanes, each in its own colour: the swatch beside
|
||||
* the name, the line, the dashed bridge over a gap, the points (hollow where
|
||||
@@ -964,156 +1033,6 @@ export function PointMark({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart as a table, for a screen reader: every entry with its lane and
|
||||
* dates. Visually hidden; the phone chronicle is the visible list form.
|
||||
*/
|
||||
function TimelineTable({
|
||||
timeline,
|
||||
hiddenLanes,
|
||||
intl,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
hiddenLanes: ReadonlySet<TimelineLaneKey>;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const words = useItemWords();
|
||||
const rows = timeline.lanes
|
||||
.filter((lane) => !hiddenLanes.has(lane.key))
|
||||
.flatMap((lane) =>
|
||||
(lane.key === "medications"
|
||||
? medicationListItems(lane.items)
|
||||
: lane.items
|
||||
).map((item) => ({ lane: lane.key, item })),
|
||||
)
|
||||
.sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start));
|
||||
return (
|
||||
// The table is hidden by a wrapper, never by `sr-only` on the table
|
||||
// itself: `overflow: hidden` does not apply to a table box, so a
|
||||
// visually hidden table still laid out its rows at full height under
|
||||
// the page and made `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-table-frame">
|
||||
<table data-slot="timeline-table">
|
||||
<caption>{t("timeline.table.caption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.lane")}</th>
|
||||
<th scope="col">{t("timeline.table.entry")}</th>
|
||||
<th scope="col">{t("timeline.table.start")}</th>
|
||||
<th scope="col">{t("timeline.table.end")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ lane, item }) => (
|
||||
<tr key={`${lane}-${item.id}`}>
|
||||
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
|
||||
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
|
||||
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
|
||||
<td>
|
||||
{!item.open && isSpan(item) && item.end
|
||||
? formatAtPrecision(item.end, item.precision, intl)
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
|
||||
export function bucketText(
|
||||
start: string,
|
||||
bucket: TimelineBucket,
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
const label = formatBucket(start, bucket, intl);
|
||||
return typeof label === "string"
|
||||
? label
|
||||
: t("timeline.selection.range", label);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value lines as a table, for a screen reader: one row per bucket from
|
||||
* the first to the last with a reading in the window, one column per
|
||||
* series. A bucket without a reading says so; nothing is filled in.
|
||||
*/
|
||||
function SeriesTable({
|
||||
timeline,
|
||||
window,
|
||||
seriesLabel,
|
||||
fmt,
|
||||
intl,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
window: { from: string; to: string };
|
||||
seriesLabel: (key: string) => string;
|
||||
fmt: SeriesValueFormat;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const { bucket, series } = timeline;
|
||||
const inWindow = (start: string) =>
|
||||
start <= window.to && bucketAfter(start, bucket) > window.from;
|
||||
const starts = series
|
||||
.flatMap((s) => s.points.map((p) => p.t))
|
||||
.filter(inWindow)
|
||||
.sort();
|
||||
if (starts.length === 0) return null;
|
||||
const rows: string[] = [];
|
||||
for (
|
||||
let start = bucketStart(starts[0], bucket);
|
||||
start <= starts[starts.length - 1];
|
||||
start = bucketAfter(start, bucket)
|
||||
) {
|
||||
rows.push(start);
|
||||
}
|
||||
const byKey = new Map(
|
||||
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
|
||||
);
|
||||
return (
|
||||
// The table is hidden by a wrapper, never by `sr-only` on the table
|
||||
// itself: `overflow: hidden` does not apply to a table box, so a
|
||||
// visually hidden table still laid out its rows at full height under
|
||||
// the page and made `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-series-table-frame">
|
||||
<table data-slot="timeline-series-table">
|
||||
<caption>{t("timeline.table.seriesCaption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.period")}</th>
|
||||
{series.map((s) => (
|
||||
<th key={s.key} scope="col">
|
||||
{seriesLabel(s.key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.reverse().map((start) => (
|
||||
<tr key={start} data-bucket={start}>
|
||||
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
|
||||
{series.map((s) => {
|
||||
const point = byKey.get(s.key)?.get(start);
|
||||
return (
|
||||
<td key={s.key}>
|
||||
{point
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
|
||||
: t("timeline.values.noValue")}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A means line with each value marked by its line's colour: "● 129/82 mmHg
|
||||
* ● 82,6 kg". The values stand apart by space, not by a separator glyph;
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* rails beside the rows an episode or a course spans, and empty stretches
|
||||
* named rather than skipped. Built from `chronicle-model.ts`.
|
||||
*
|
||||
* A lab day's row lists the chosen lab lines' readings of that day under its
|
||||
* names, each as measured (#1299); its report is a step further, in the day.
|
||||
*
|
||||
* A dated row opens its day (the same `?day=` layer the chart hands off to);
|
||||
* a life event opens its edit sheet instead, because the event is the
|
||||
* person's own entry and that is where it is changed.
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
type ChronicleRails,
|
||||
} from "./chronicle-model";
|
||||
import { useItemWords } from "./item-words";
|
||||
import { ChronicleLabReadings } from "./lab-lines";
|
||||
import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys";
|
||||
import {
|
||||
formatAtPrecision,
|
||||
@@ -50,12 +54,8 @@ import {
|
||||
formatMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { LANE_COLOR } from "./timeline-geometry";
|
||||
import {
|
||||
MEAN_PARTS_ROW,
|
||||
MeanPartsLine,
|
||||
bucketText,
|
||||
laneDotStyle,
|
||||
} from "./timeline-chart";
|
||||
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
|
||||
import { bucketText } from "./timeline-tables";
|
||||
import {
|
||||
chronicleMeans,
|
||||
meanParts,
|
||||
@@ -131,6 +131,52 @@ function Rails({ rails }: { rails: ChronicleRails }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A chronicle row's title and the muted line under it. */
|
||||
function entryText(
|
||||
entry: ChronicleEntry,
|
||||
{
|
||||
t,
|
||||
tCount,
|
||||
words,
|
||||
}: {
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
tCount: ReturnType<typeof useTranslations>["tCount"];
|
||||
words: ReturnType<typeof useItemWords>;
|
||||
},
|
||||
): {
|
||||
title: string;
|
||||
meta: string | null;
|
||||
} {
|
||||
if (entry.kind === "notable") {
|
||||
return {
|
||||
title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]),
|
||||
meta: t("timeline.chronicle.notableMeta"),
|
||||
};
|
||||
}
|
||||
const { item, role } = entry;
|
||||
const { label, sub, tag } = words(item);
|
||||
if (role === "end") {
|
||||
// A pause that ends is the medication taken up again, not ended.
|
||||
return {
|
||||
title:
|
||||
item.kind === "pause"
|
||||
? t("day.event.resumed", { label: item.label })
|
||||
: t("timeline.chronicle.ended", { label }),
|
||||
meta: entry.days
|
||||
? tCount("timeline.chronicle.afterDays", entry.days)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
// A life event's category sits on the muted line under its title.
|
||||
const meta = [
|
||||
sub ?? tag ?? null,
|
||||
item.startKnown ? null : t("timeline.startUnknown"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return { title: label, meta: meta || null };
|
||||
}
|
||||
|
||||
export function TimelineChronicle({
|
||||
timeline,
|
||||
today,
|
||||
@@ -185,40 +231,6 @@ export function TimelineChronicle({
|
||||
});
|
||||
};
|
||||
|
||||
function entryText(entry: ChronicleEntry): {
|
||||
title: string;
|
||||
meta: string | null;
|
||||
} {
|
||||
if (entry.kind === "notable") {
|
||||
return {
|
||||
title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]),
|
||||
meta: t("timeline.chronicle.notableMeta"),
|
||||
};
|
||||
}
|
||||
const { item, role } = entry;
|
||||
const { label, sub, tag } = words(item);
|
||||
if (role === "end") {
|
||||
// A pause that ends is the medication taken up again, not ended.
|
||||
return {
|
||||
title:
|
||||
item.kind === "pause"
|
||||
? t("day.event.resumed", { label: item.label })
|
||||
: t("timeline.chronicle.ended", { label }),
|
||||
meta: entry.days
|
||||
? tCount("timeline.chronicle.afterDays", entry.days)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
// A life event's category sits on the muted line under its title.
|
||||
const meta = [
|
||||
sub ?? tag ?? null,
|
||||
item.startKnown ? null : t("timeline.startUnknown"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return { title: label, meta: meta || null };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="timeline-chronicle">
|
||||
{chips.length > 0 && (
|
||||
@@ -314,7 +326,7 @@ export function TimelineChronicle({
|
||||
);
|
||||
}
|
||||
const { entry } = row;
|
||||
const { title, meta } = entryText(entry);
|
||||
const { title, meta } = entryText(entry, { t, tCount, words });
|
||||
const Icon =
|
||||
entry.kind === "notable"
|
||||
? Sparkles
|
||||
@@ -367,6 +379,12 @@ export function TimelineChronicle({
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
<ChronicleLabReadings
|
||||
entry={entry}
|
||||
labSeries={timeline.labSeries}
|
||||
seriesColor={seriesColor}
|
||||
className={cn(MEAN_PARTS_ROW, "mt-1")}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
TimelineBucket,
|
||||
TimelineItem,
|
||||
TimelineItemKind,
|
||||
TimelineLabSeries,
|
||||
TimelineLane,
|
||||
TimelineLaneKey,
|
||||
TimelineSeries,
|
||||
@@ -27,6 +28,13 @@ import type {
|
||||
} from "@/lib/day/contract";
|
||||
|
||||
import { itemLine, type ItemWordsFn } from "./item-words";
|
||||
import {
|
||||
SERIES_HEIGHT,
|
||||
layoutLabSeries,
|
||||
layoutSeries,
|
||||
type LabSeriesLayout,
|
||||
type SeriesLayout,
|
||||
} from "./series-geometry";
|
||||
import {
|
||||
doseSegments,
|
||||
medicationGroups,
|
||||
@@ -36,8 +44,6 @@ import {
|
||||
} from "./medication-rows";
|
||||
import {
|
||||
addMonths,
|
||||
bucketAfter,
|
||||
bucketIndex,
|
||||
dayKey,
|
||||
dayNumber,
|
||||
endOfMonth,
|
||||
@@ -57,8 +63,6 @@ export const ROW_HEIGHT = 22;
|
||||
export const LANE_PAD_BOTTOM = 18;
|
||||
/** Gap between the last lane and the first value line. */
|
||||
export const SERIES_GAP = 18;
|
||||
/** Height of one value line, label included. */
|
||||
export const SERIES_HEIGHT = 60;
|
||||
/** Label font size in the lanes, px. */
|
||||
export const LABEL_FONT_PX = 11;
|
||||
/** Estimated advance of one label character at 11 px, on the wide side. */
|
||||
@@ -1051,143 +1055,6 @@ function placeLabels(
|
||||
return labels;
|
||||
}
|
||||
|
||||
/* ─── Value lines ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SeriesPointLayout {
|
||||
/** The bucket's first day. */
|
||||
t: string;
|
||||
mean: number;
|
||||
/** Readings behind the mean. */
|
||||
count: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
|
||||
thin: boolean;
|
||||
}
|
||||
|
||||
export interface SeriesLayout {
|
||||
key: string;
|
||||
unit: string | null;
|
||||
top: number;
|
||||
height: number;
|
||||
/** SVG path data of the solid runs; empty when no two points touch. */
|
||||
path: string;
|
||||
/** SVG path data of the dashed bridges over short gaps; may be empty. */
|
||||
bridges: string;
|
||||
/** The most recent mean inside the window, or null. */
|
||||
latest: number | null;
|
||||
points: SeriesPointLayout[];
|
||||
}
|
||||
|
||||
/** A bucket mean from fewer readings than this is drawn hollow. */
|
||||
export const THIN_BUCKET_COUNT = 3;
|
||||
|
||||
/**
|
||||
* The most missing buckets a dashed bridge spans. Beyond it the line stops
|
||||
* and starts again, so a long silence reads as one and not as a trend.
|
||||
*
|
||||
* The rule is one length, about six months between the two points a bridge
|
||||
* joins, never more, and never more than a third of what the zoom shows:
|
||||
* one missing quarter (the points are six months apart), two missing months
|
||||
* in a year (three months apart, a sixth of the window) and four missing
|
||||
* weeks in three months (five weeks apart, the window's third). A longer
|
||||
* bridge would draw a slope through a stretch where nothing was measured.
|
||||
*/
|
||||
export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
|
||||
quarter: 1,
|
||||
month: 2,
|
||||
week: 4,
|
||||
// Days come only with a chosen range under six weeks. Three missing days
|
||||
// (points four days apart) bridge a weekend away or a forgotten cuff;
|
||||
// a longer silence in a window that short is a break worth seeing, and
|
||||
// the bridge stays well under a third of even a two-week range.
|
||||
day: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* One neutral line per series, scaled to its own range inside the window.
|
||||
* A point sits in the middle of its bucket, and every bucket with a reading
|
||||
* gets its point, so one month with no neighbours stays visible. Missing
|
||||
* buckets stay missing: a short gap is bridged by a dashed stroke
|
||||
* ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
|
||||
* between them, a longer one breaks the line.
|
||||
*/
|
||||
export function layoutSeries(
|
||||
series: readonly TimelineSeries[],
|
||||
top: number,
|
||||
window: TimeWindow,
|
||||
scale: Scale,
|
||||
bucket: TimelineBucket,
|
||||
): SeriesLayout[] {
|
||||
return series.map((s, index) => {
|
||||
const rowTop = top + index * SERIES_HEIGHT + 6;
|
||||
const height = SERIES_HEIGHT - 16;
|
||||
// A bucket that starts before the window still belongs to it while any
|
||||
// of its days lie inside.
|
||||
const inside = s.points
|
||||
.filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
|
||||
.sort((a, b) => (a.t < b.t ? -1 : 1));
|
||||
if (inside.length === 0) {
|
||||
return {
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
top: rowTop,
|
||||
height,
|
||||
path: "",
|
||||
bridges: "",
|
||||
latest: null,
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
let lo = Math.min(...inside.map((p) => p.mean));
|
||||
let hi = Math.max(...inside.map((p) => p.mean));
|
||||
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
|
||||
const points = inside.map((p) => {
|
||||
const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
|
||||
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
|
||||
return {
|
||||
t: p.t,
|
||||
mean: p.mean,
|
||||
count: p.count,
|
||||
x,
|
||||
y: y(p.mean),
|
||||
thin: p.count < THIN_BUCKET_COUNT,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
|
||||
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
|
||||
let path = "";
|
||||
let bridges = "";
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
const missing =
|
||||
bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
|
||||
if (missing === 0) {
|
||||
// Continue the run, or open a new one at the previous point.
|
||||
path += path.endsWith(xy(prev))
|
||||
? `L${xy(cur)}`
|
||||
: `M${xy(prev)}L${xy(cur)}`;
|
||||
} else if (missing <= MAX_BRIDGED_GAP[bucket]) {
|
||||
bridges += `M${xy(prev)}L${xy(cur)}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: s.key,
|
||||
unit: s.unit,
|
||||
top: rowTop,
|
||||
height,
|
||||
path,
|
||||
bridges,
|
||||
latest: inside[inside.length - 1].mean,
|
||||
points,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TimelineLayout {
|
||||
@@ -1197,6 +1064,8 @@ export interface TimelineLayout {
|
||||
lanes: LaneLayout[];
|
||||
seriesTop: number;
|
||||
series: SeriesLayout[];
|
||||
/** The lab lines, below the value lines (#1299). */
|
||||
labSeries: LabSeriesLayout[];
|
||||
}
|
||||
|
||||
/** Width of the lane-name column at a given chart width. */
|
||||
@@ -1209,6 +1078,8 @@ export function layoutTimeline(input: {
|
||||
window: TimeWindow;
|
||||
lanes: readonly TimelineLane[];
|
||||
series: readonly TimelineSeries[];
|
||||
/** Lab lines, drawn under the value lines; none by default. */
|
||||
labSeries?: readonly TimelineLabSeries[];
|
||||
bucket: TimelineBucket;
|
||||
words: ItemWordsFn;
|
||||
startMissing: string;
|
||||
@@ -1231,7 +1102,9 @@ export function layoutTimeline(input: {
|
||||
lanes.push(layout);
|
||||
top += layout.height;
|
||||
}
|
||||
const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
|
||||
const labInput = input.labSeries ?? [];
|
||||
const seriesTop =
|
||||
top + (input.series.length + labInput.length > 0 ? SERIES_GAP : 0);
|
||||
const series = layoutSeries(
|
||||
input.series,
|
||||
seriesTop,
|
||||
@@ -1239,8 +1112,23 @@ export function layoutTimeline(input: {
|
||||
scale,
|
||||
input.bucket,
|
||||
);
|
||||
const height = seriesTop + series.length * SERIES_HEIGHT + 4;
|
||||
return { width: input.width, height, scale, lanes, seriesTop, series };
|
||||
const labSeries = layoutLabSeries(
|
||||
labInput,
|
||||
seriesTop + series.length * SERIES_HEIGHT,
|
||||
input.window,
|
||||
scale,
|
||||
);
|
||||
const height =
|
||||
seriesTop + (series.length + labSeries.length) * SERIES_HEIGHT + 4;
|
||||
return {
|
||||
width: input.width,
|
||||
height,
|
||||
scale,
|
||||
lanes,
|
||||
seriesTop,
|
||||
series,
|
||||
labSeries,
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── Interaction ─────────────────────────────────────────────────────────── */
|
||||
@@ -1364,6 +1252,7 @@ export function latestDataDate(
|
||||
timeline: {
|
||||
lanes: readonly TimelineLane[];
|
||||
series: readonly TimelineSeries[];
|
||||
labSeries?: readonly TimelineLabSeries[];
|
||||
},
|
||||
today: string,
|
||||
window: TimeWindow,
|
||||
@@ -1391,5 +1280,9 @@ export function latestDataDate(
|
||||
for (const series of timeline.series) {
|
||||
for (const point of series.points) consider(point.t);
|
||||
}
|
||||
// A lab reading counts on its own day.
|
||||
for (const series of timeline.labSeries ?? []) {
|
||||
for (const point of series.points) consider(point.date);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The timeline's legend (v1.42, #613; lab marks #1299).
|
||||
*/
|
||||
import type { TimelineBucket, TimelineLabSeries } from "@/lib/day/contract";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { TIMELINE_LEGEND_MEAN_KEY } from "./label-keys";
|
||||
import { LabExcludedNote } from "./lab-lines";
|
||||
|
||||
/**
|
||||
* What the marks mean. Under the selection bar and set off from it by a
|
||||
* hairline. With value lines, the two marks a line can carry besides its
|
||||
* points are explained by an example of each: the dashed stroke over a short
|
||||
* gap without measurements, and the hollow point of a mean resting on fewer
|
||||
* than three.
|
||||
*/
|
||||
export function TimelineLegend({
|
||||
bucket,
|
||||
hasSeries,
|
||||
labSeries = [],
|
||||
label = (key) => key,
|
||||
}: {
|
||||
bucket: TimelineBucket;
|
||||
hasSeries: boolean;
|
||||
/**
|
||||
* The lab lines drawn: their three marks are explained, and what a line
|
||||
* left out is said here, where the marks are (#1299).
|
||||
*/
|
||||
labSeries?: readonly TimelineLabSeries[];
|
||||
label?: (key: string) => string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const hasLabs = labSeries.length > 0;
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground border-border flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-3 text-xs"
|
||||
data-slot="timeline-legend"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendSpan")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground size-2 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendEvent")}
|
||||
</span>
|
||||
{hasSeries && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-mean"
|
||||
data-bucket={bucket}
|
||||
>
|
||||
<span
|
||||
className="bg-foreground h-0.5 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-gap"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="8"
|
||||
viewBox="0 0 16 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="1"
|
||||
x2="15"
|
||||
y1="4"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="3 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendGap")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-thin"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="4"
|
||||
cy="4"
|
||||
r="2.75"
|
||||
fill="var(--card)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendThin")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasLabs && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-lab-value"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="4" cy="4" r="3.25" fill="currentColor" />
|
||||
</svg>
|
||||
{t("timeline.legendLabValue")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-lab-outside"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 0.5L9.5 8.5L0.5 8.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
{t("timeline.legendLabOutside")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-lab-range"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="8"
|
||||
viewBox="0 0 16 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="0"
|
||||
y="0.5"
|
||||
width="16"
|
||||
height="7"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.1"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
x2="16"
|
||||
y1="0.5"
|
||||
y2="0.5"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.4"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
x2="16"
|
||||
y1="7.5"
|
||||
y2="7.5"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.4"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendLabRange")}
|
||||
</span>
|
||||
<LabExcludedNote labSeries={labSeries} label={label} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@
|
||||
*
|
||||
* The value menu is one compact button, "Values (3)". The chosen lines are
|
||||
* not listed again beside it: each one is a named row under the lanes, and
|
||||
* the menu marks it with its colour.
|
||||
* the menu marks it with its colour. Below the measurements it offers the
|
||||
* lab markers the record has values for (#1299), under the groups the labs
|
||||
* list sorts them into; a lab line is chosen and stored like any other, up
|
||||
* to `TIMELINE_LAB_SERIES_MAX` at once: past it the unchosen markers are
|
||||
* held back with a line that says why, and a chosen one can always go.
|
||||
*/
|
||||
import { ChartLine, ChevronDown, Layers } from "lucide-react";
|
||||
|
||||
@@ -16,44 +20,99 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { TimelineLaneKey } from "@/lib/day/contract";
|
||||
import {
|
||||
TIMELINE_LAB_SERIES_MAX,
|
||||
timelineLabMarkerId,
|
||||
type TimelineLaneKey,
|
||||
type TimelineResponse,
|
||||
} from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
|
||||
|
||||
/** Lab markers under one group of the labs list (null: no group). */
|
||||
export interface LabOptionGroup {
|
||||
panel: string | null;
|
||||
keys: readonly string[];
|
||||
}
|
||||
|
||||
export function ValueSeriesMenu({
|
||||
options,
|
||||
labGroups = [],
|
||||
selected,
|
||||
label,
|
||||
color,
|
||||
onChange,
|
||||
iconOnly = false,
|
||||
}: {
|
||||
options: readonly string[];
|
||||
/** The record's lab markers, grouped as the labs list groups them. */
|
||||
labGroups?: readonly LabOptionGroup[];
|
||||
selected: readonly string[];
|
||||
label: (key: string) => string;
|
||||
/** A chosen line's colour, shown as a dot beside its name. */
|
||||
color: (key: string) => string;
|
||||
onChange: (next: string[]) => void;
|
||||
/** The phone toolbar's square button, named by its label. */
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const labsFull =
|
||||
selected.filter((key) => timelineLabMarkerId(key) !== null).length >=
|
||||
TIMELINE_LAB_SERIES_MAX;
|
||||
const option = (key: string) => {
|
||||
const checked = selected.includes(key);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={checked}
|
||||
disabled={labsFull && !checked && timelineLabMarkerId(key) !== null}
|
||||
data-slot="timeline-values-option"
|
||||
data-key={key}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(next) =>
|
||||
onChange(
|
||||
next ? [...selected, key] : selected.filter((k) => k !== key),
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* The dot is the line's colour on the chart; an unchosen
|
||||
value has no line yet, so its place stays empty. */}
|
||||
<span
|
||||
data-slot="timeline-values-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={checked ? { background: color(key) } : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
};
|
||||
const trigger = t("timeline.values.trigger", { count: selected.length });
|
||||
// A group is named once there is more than one, or when it has a name.
|
||||
const namedGroups =
|
||||
labGroups.length > 1 || labGroups.some((g) => g.panel !== null);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
size={iconOnly ? "icon" : "sm"}
|
||||
className={iconOnly ? "size-11" : "min-h-11 sm:min-h-9"}
|
||||
aria-label={iconOnly ? trigger : undefined}
|
||||
data-slot="timeline-values-trigger"
|
||||
data-count={selected.length}
|
||||
>
|
||||
<ChartLine className="size-4" aria-hidden="true" />
|
||||
{t("timeline.values.trigger", { count: selected.length })}
|
||||
<ChevronDown className="size-4" aria-hidden="true" />
|
||||
{!iconOnly && trigger}
|
||||
{!iconOnly && <ChevronDown className="size-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -62,33 +121,36 @@ export function ValueSeriesMenu({
|
||||
data-slot="timeline-values-menu"
|
||||
>
|
||||
<DropdownMenuLabel>{t("timeline.values.menuTitle")}</DropdownMenuLabel>
|
||||
{options.map((key) => {
|
||||
const checked = selected.includes(key);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={checked}
|
||||
data-slot="timeline-values-option"
|
||||
data-key={key}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(next) =>
|
||||
onChange(
|
||||
next ? [...selected, key] : selected.filter((k) => k !== key),
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* The dot is the line's colour on the chart; an unchosen
|
||||
value has no line yet, so its place stays empty. */}
|
||||
<span
|
||||
data-slot="timeline-values-dot"
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={checked ? { background: color(key) } : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label(key)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
{options.map(option)}
|
||||
{labGroups.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel data-slot="timeline-values-labs">
|
||||
{t("timeline.labs.menuTitle")}
|
||||
</DropdownMenuLabel>
|
||||
{labsFull && (
|
||||
<p
|
||||
data-slot="timeline-values-labs-full"
|
||||
className="text-muted-foreground px-2 pb-1 text-xs"
|
||||
>
|
||||
{t("timeline.labs.full", { max: TIMELINE_LAB_SERIES_MAX })}
|
||||
</p>
|
||||
)}
|
||||
{labGroups.map((group) => (
|
||||
<DropdownMenuGroup
|
||||
key={group.panel ?? ""}
|
||||
data-slot="timeline-values-lab-group"
|
||||
>
|
||||
{namedGroups && (
|
||||
<DropdownMenuLabel className="text-muted-foreground pt-2 pb-1 text-xs font-medium tracking-wide uppercase">
|
||||
{group.panel ?? t("timeline.labs.otherPanel")}
|
||||
</DropdownMenuLabel>
|
||||
)}
|
||||
{group.keys.map(option)}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -147,3 +209,102 @@ export function LayersMenu({
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lab markers of the menu, grouped as the labs list groups them: by the
|
||||
* marker's group, named groups first, each in the reader's alphabetical
|
||||
* order, markers inside a group likewise.
|
||||
*/
|
||||
export function labOptionGroups(
|
||||
markers: ReadonlyArray<{ key: string; name: string; panel: string | null }>,
|
||||
intl: string,
|
||||
): LabOptionGroup[] {
|
||||
const collator = new Intl.Collator(intl, { sensitivity: "base" });
|
||||
const groups = new Map<string | null, typeof markers>();
|
||||
const seen = new Set<string>();
|
||||
for (const marker of markers) {
|
||||
if (seen.has(marker.key)) continue;
|
||||
seen.add(marker.key);
|
||||
const panel = marker.panel?.trim() || null;
|
||||
groups.set(panel, [...(groups.get(panel) ?? []), marker]);
|
||||
}
|
||||
return [...groups]
|
||||
.sort(([a], [b]) =>
|
||||
a === null ? 1 : b === null ? -1 : collator.compare(a, b),
|
||||
)
|
||||
.map(([panel, list]) => ({
|
||||
panel,
|
||||
keys: [...list]
|
||||
.sort((a, b) => collator.compare(a.name, b.name))
|
||||
.map((m) => m.key),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Each lab marker's name by its key, from what the server sent. */
|
||||
export function labMarkerNames(
|
||||
timeline: Pick<TimelineResponse, "availableLabs" | "labSeries"> | undefined,
|
||||
): Map<string, string> {
|
||||
return new Map(
|
||||
[...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map(
|
||||
(m) => [m.key, m.name],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The values menu as the page holds it: every series the record has, the
|
||||
* chosen ones kept even when they have nothing yet, in `rank`'s order and
|
||||
* then the record's; then the record's lab markers by group. It counts and
|
||||
* toggles the lines it can name, so a stored marker the record no longer
|
||||
* has drops out with the next change.
|
||||
*/
|
||||
export function TimelineValuesMenu({
|
||||
timeline,
|
||||
values,
|
||||
allowed,
|
||||
rank,
|
||||
labsOn,
|
||||
label,
|
||||
color,
|
||||
onChange,
|
||||
iconOnly,
|
||||
}: {
|
||||
timeline: TimelineResponse | undefined;
|
||||
values: readonly string[];
|
||||
allowed: (key: string) => boolean;
|
||||
rank: readonly string[];
|
||||
labsOn: boolean;
|
||||
label: (key: string) => string;
|
||||
color: (key: string) => string;
|
||||
onChange: (next: string[]) => void;
|
||||
iconOnly: boolean;
|
||||
}) {
|
||||
const { locale } = useTranslations();
|
||||
const options = [
|
||||
...new Set([
|
||||
...(timeline?.availableSeries ?? []).filter(allowed),
|
||||
...values.filter((key) => timelineLabMarkerId(key) === null),
|
||||
]),
|
||||
].sort((a, b) => rank.indexOf(a) - rank.indexOf(b));
|
||||
const markers = new Map(
|
||||
[...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map(
|
||||
(m) => [m.key, m],
|
||||
),
|
||||
);
|
||||
const labGroups = labsOn
|
||||
? labOptionGroups([...markers.values()], resolveIntlLocale(locale))
|
||||
: [];
|
||||
return (
|
||||
<ValueSeriesMenu
|
||||
options={options}
|
||||
labGroups={labGroups}
|
||||
selected={values.filter(
|
||||
(key) => timelineLabMarkerId(key) === null || markers.has(key),
|
||||
)}
|
||||
label={label}
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
iconOnly={iconOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The timeline's chart as tables, for a screen reader (v1.42, #613): the
|
||||
* lane entries with their dates, and the value lines' means per bucket.
|
||||
* Both are visually hidden; the phone chronicle is the visible list form.
|
||||
* The lab lines' table sits with the lab lines (`lab-lines.tsx`).
|
||||
*/
|
||||
import type {
|
||||
TimelineBucket,
|
||||
TimelineLaneKey,
|
||||
TimelineResponse,
|
||||
} from "@/lib/day/contract";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { itemLine, useItemWords } from "./item-words";
|
||||
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
|
||||
import { medicationListItems } from "./medication-rows";
|
||||
import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
|
||||
import {
|
||||
bucketAfter,
|
||||
bucketStart,
|
||||
dayNumber,
|
||||
formatAtPrecision,
|
||||
formatBucket,
|
||||
} from "./timeline-dates";
|
||||
import { isSpan } from "./timeline-geometry";
|
||||
|
||||
/**
|
||||
* The chart as a table, for a screen reader: every entry with its lane and
|
||||
* dates. Visually hidden; the phone chronicle is the visible list form.
|
||||
*/
|
||||
export function TimelineTable({
|
||||
timeline,
|
||||
hiddenLanes,
|
||||
intl,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
hiddenLanes: ReadonlySet<TimelineLaneKey>;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const words = useItemWords();
|
||||
const rows = timeline.lanes
|
||||
.filter((lane) => !hiddenLanes.has(lane.key))
|
||||
.flatMap((lane) =>
|
||||
(lane.key === "medications"
|
||||
? medicationListItems(lane.items)
|
||||
: lane.items
|
||||
).map((item) => ({ lane: lane.key, item })),
|
||||
)
|
||||
.sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start));
|
||||
return (
|
||||
// The table is hidden by a wrapper, never by `sr-only` on the table
|
||||
// itself: `overflow: hidden` does not apply to a table box, so a
|
||||
// visually hidden table still laid out its rows at full height under
|
||||
// the page and made `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-table-frame">
|
||||
<table data-slot="timeline-table">
|
||||
<caption>{t("timeline.table.caption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.lane")}</th>
|
||||
<th scope="col">{t("timeline.table.entry")}</th>
|
||||
<th scope="col">{t("timeline.table.start")}</th>
|
||||
<th scope="col">{t("timeline.table.end")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ lane, item }) => (
|
||||
<tr key={`${lane}-${item.id}`}>
|
||||
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
|
||||
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
|
||||
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
|
||||
<td>
|
||||
{!item.open && isSpan(item) && item.end
|
||||
? formatAtPrecision(item.end, item.precision, intl)
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
|
||||
export function bucketText(
|
||||
start: string,
|
||||
bucket: TimelineBucket,
|
||||
intl: string,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
const label = formatBucket(start, bucket, intl);
|
||||
return typeof label === "string"
|
||||
? label
|
||||
: t("timeline.selection.range", label);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value lines as a table, for a screen reader: one row per bucket from
|
||||
* the first to the last with a reading in the window, one column per
|
||||
* series. A bucket without a reading says so; nothing is filled in.
|
||||
*/
|
||||
export function SeriesTable({
|
||||
timeline,
|
||||
window,
|
||||
seriesLabel,
|
||||
fmt,
|
||||
intl,
|
||||
}: {
|
||||
timeline: TimelineResponse;
|
||||
window: { from: string; to: string };
|
||||
seriesLabel: (key: string) => string;
|
||||
fmt: SeriesValueFormat;
|
||||
intl: string;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const { bucket, series } = timeline;
|
||||
const inWindow = (start: string) =>
|
||||
start <= window.to && bucketAfter(start, bucket) > window.from;
|
||||
const starts = series
|
||||
.flatMap((s) => s.points.map((p) => p.t))
|
||||
.filter(inWindow)
|
||||
.sort();
|
||||
if (starts.length === 0) return null;
|
||||
const rows: string[] = [];
|
||||
for (
|
||||
let start = bucketStart(starts[0], bucket);
|
||||
start <= starts[starts.length - 1];
|
||||
start = bucketAfter(start, bucket)
|
||||
) {
|
||||
rows.push(start);
|
||||
}
|
||||
const byKey = new Map(
|
||||
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
|
||||
);
|
||||
return (
|
||||
// The table is hidden by a wrapper, never by `sr-only` on the table
|
||||
// itself: `overflow: hidden` does not apply to a table box, so a
|
||||
// visually hidden table still laid out its rows at full height under
|
||||
// the page and made `<main>` scroll past its last card into nothing.
|
||||
<div className="sr-only" data-slot="timeline-series-table-frame">
|
||||
<table data-slot="timeline-series-table">
|
||||
<caption>{t("timeline.table.seriesCaption")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("timeline.table.period")}</th>
|
||||
{series.map((s) => (
|
||||
<th key={s.key} scope="col">
|
||||
{seriesLabel(s.key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.reverse().map((start) => (
|
||||
<tr key={start} data-bucket={start}>
|
||||
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
|
||||
{series.map((s) => {
|
||||
const point = byKey.get(s.key)?.get(start);
|
||||
return (
|
||||
<td key={s.key}>
|
||||
{point
|
||||
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
|
||||
: t("timeline.values.noValue")}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
* and Back closes it. An incoming `?day=` selects that day and zooms to
|
||||
* three months, so a day's "In the timeline" lands on itself.
|
||||
*
|
||||
* Lab markers the record has values for are offered beside the measurements
|
||||
* (#1299): a chosen marker is stored and requested like any other line, as
|
||||
* `lab:<biomarkerId>`, and drawn as its single readings, never as means.
|
||||
*
|
||||
* At 768 px and up the chart; below it the chronicle, which is also the
|
||||
* list form of the chart. The readiness card sits on top while a lane is
|
||||
* thin and the person has not closed it; empty lanes are never drawn.
|
||||
@@ -33,10 +37,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
capTimelineLabKeys,
|
||||
DAY_QUERY_PARAM,
|
||||
TIMELINE_ZOOMS,
|
||||
timelineLabMarkerId,
|
||||
type LifeEventDTO,
|
||||
type TimelineBucket,
|
||||
type TimelineLaneKey,
|
||||
type TimelineResponse,
|
||||
type TimelineZoom,
|
||||
@@ -49,9 +54,10 @@ import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ChronicleGrouping } from "./chronicle-model";
|
||||
import {
|
||||
TIMELINE_BLOOD_PRESSURE_SERIES_KEY,
|
||||
TIMELINE_LEGEND_MEAN_KEY,
|
||||
TIMELINE_ZOOM_LABEL_KEY,
|
||||
} from "./label-keys";
|
||||
import { LabExcludedNote } from "./lab-lines";
|
||||
import { TimelineLegend } from "./timeline-legend";
|
||||
import { LifeEventSheet } from "./life-event-sheet";
|
||||
import { ReadinessCard } from "./readiness-card";
|
||||
import {
|
||||
@@ -78,7 +84,11 @@ import {
|
||||
type TimelineRange,
|
||||
} from "./timeline-url";
|
||||
import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry";
|
||||
import { LayersMenu, ValueSeriesMenu } from "./timeline-menus";
|
||||
import {
|
||||
LayersMenu,
|
||||
TimelineValuesMenu,
|
||||
labMarkerNames,
|
||||
} from "./timeline-menus";
|
||||
import {
|
||||
isBoolean,
|
||||
isStringArray,
|
||||
@@ -140,13 +150,58 @@ function timelineIsEmpty(data: TimelineResponse): boolean {
|
||||
return (
|
||||
data.lanes.every((l) => l.items.length === 0) &&
|
||||
data.standing.length === 0 &&
|
||||
data.series.every((s) => s.points.length === 0)
|
||||
data.series.every((s) => s.points.length === 0) &&
|
||||
data.labSeries.every((s) => s.points.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
const VALUES_KEY = "healthlog.timeline.values";
|
||||
const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes";
|
||||
|
||||
/**
|
||||
* The value lines asked for: the stored choice, minus anything unknown and
|
||||
* minus a line whose module is off, and one colour per chosen line.
|
||||
*/
|
||||
function useValueLines(
|
||||
modules: Partial<Record<ModuleKey, boolean>> | undefined,
|
||||
) {
|
||||
const [storedValues, setStoredValues] = useDevicePreference(
|
||||
VALUES_KEY,
|
||||
DEFAULT_VALUES,
|
||||
isStringArray,
|
||||
);
|
||||
// What is asked for: the stored choice, minus anything unknown and minus
|
||||
// a line whose module is off. The server narrows it further to what the
|
||||
// caller may see.
|
||||
const allowed = useCallback(
|
||||
(key: string) => {
|
||||
// A lab line belongs to the labs module; whether the record has the
|
||||
// marker is the server's to say.
|
||||
if (timelineLabMarkerId(key) !== null) return modules?.labs !== false;
|
||||
if (!KNOWN_SERIES.has(key)) return false;
|
||||
const owner: ModuleKey | null | undefined =
|
||||
VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
|
||||
return !owner || modules?.[owner] !== false;
|
||||
},
|
||||
[modules],
|
||||
);
|
||||
// Lab markers past the cap are not asked for (the server would leave
|
||||
// them out); the menu holds further ones back.
|
||||
const values = useMemo(
|
||||
() => capTimelineLabKeys(storedValues.filter(allowed)),
|
||||
[storedValues, allowed],
|
||||
);
|
||||
// One colour per chosen line, shared by the chart, the selection bar, the
|
||||
// menu and the phone chronicle; contested colours go by the menu's order.
|
||||
const seriesColors = useMemo(
|
||||
() => assignSeriesColors(values, SERIES_RANK),
|
||||
[values],
|
||||
);
|
||||
const seriesColor = (key: string) =>
|
||||
seriesColors.get(key) ?? SERIES_PALETTE[0];
|
||||
return { values, allowed, setStoredValues, seriesColor };
|
||||
}
|
||||
|
||||
export function TimelineView() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -183,11 +238,6 @@ export function TimelineView() {
|
||||
const [lifeEventOpen, setLifeEventOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LifeEventDTO | null>(null);
|
||||
|
||||
const [storedValues, setStoredValues] = useDevicePreference(
|
||||
VALUES_KEY,
|
||||
DEFAULT_VALUES,
|
||||
isStringArray,
|
||||
);
|
||||
const [hiddenList, setHiddenList] = useDevicePreference<string[]>(
|
||||
HIDDEN_LANES_KEY,
|
||||
[],
|
||||
@@ -200,31 +250,9 @@ export function TimelineView() {
|
||||
);
|
||||
|
||||
const modules = user?.modules;
|
||||
// What is asked for: the stored choice, minus anything unknown and minus
|
||||
// a line whose module is off. The server narrows it further to what the
|
||||
// caller may see.
|
||||
const allowed = useCallback(
|
||||
(key: string) => {
|
||||
if (!KNOWN_SERIES.has(key)) return false;
|
||||
const owner: ModuleKey | null | undefined =
|
||||
VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
|
||||
return !owner || modules?.[owner] !== false;
|
||||
},
|
||||
[modules],
|
||||
);
|
||||
const values = useMemo(
|
||||
() => storedValues.filter(allowed),
|
||||
[storedValues, allowed],
|
||||
);
|
||||
// One colour per chosen line, shared by the chart, the selection bar, the
|
||||
// menu and the phone chronicle; contested colours go by the menu's order.
|
||||
const seriesColors = useMemo(
|
||||
() => assignSeriesColors(values, SERIES_RANK),
|
||||
[values],
|
||||
);
|
||||
const { values, allowed, setStoredValues, seriesColor } =
|
||||
useValueLines(modules);
|
||||
const seriesFormat = useSeriesValueFormat();
|
||||
const seriesColor = (key: string) =>
|
||||
seriesColors.get(key) ?? SERIES_PALETTE[0];
|
||||
const hidden = useMemo(
|
||||
() => new Set(hiddenList as TimelineLaneKey[]),
|
||||
[hiddenList],
|
||||
@@ -334,14 +362,17 @@ export function TimelineView() {
|
||||
setLifeEventOpen(true);
|
||||
}
|
||||
|
||||
const labNames = labMarkerNames(data);
|
||||
// Blood pressure reads as its own name here; the list labels ("Sys",
|
||||
// "Dia") lean on a column header the chart does not have.
|
||||
// "Dia") lean on a column header the chart does not have. A lab marker
|
||||
// reads by its own name, as on the labs list.
|
||||
const seriesLabel = (key: string) =>
|
||||
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
|
||||
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
|
||||
: key === "MOOD"
|
||||
? t("timeline.readiness.lanes.mood")
|
||||
: t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key);
|
||||
: (labNames.get(key) ??
|
||||
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
|
||||
|
||||
const shownWindow = data
|
||||
? windowFor(
|
||||
@@ -367,12 +398,19 @@ export function TimelineView() {
|
||||
today,
|
||||
data?.range.dataFrom ?? null,
|
||||
);
|
||||
// The selector offers every series the record has, the chosen ones kept
|
||||
// even when they have nothing yet, in the selector's order and then the
|
||||
// record's.
|
||||
const valueOptions = [
|
||||
...new Set([...(data?.availableSeries ?? []).filter(allowed), ...values]),
|
||||
].sort((a, b) => SERIES_RANK.indexOf(a) - SERIES_RANK.indexOf(b));
|
||||
const valuesMenu = (iconOnly: boolean) => (
|
||||
<TimelineValuesMenu
|
||||
timeline={data}
|
||||
values={values}
|
||||
allowed={allowed}
|
||||
rank={SERIES_RANK}
|
||||
labsOn={modules?.labs !== false}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
iconOnly={iconOnly}
|
||||
/>
|
||||
);
|
||||
const presentLanes = (data?.lanes ?? [])
|
||||
.filter((l) => l.items.length > 0)
|
||||
.map((l) => l.key)
|
||||
@@ -532,13 +570,7 @@ export function TimelineView() {
|
||||
onApply={(next) => navigate("range", next)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<ValueSeriesMenu
|
||||
options={valueOptions}
|
||||
selected={values}
|
||||
label={seriesLabel}
|
||||
color={seriesColor}
|
||||
onChange={setStoredValues}
|
||||
/>
|
||||
{valuesMenu(false)}
|
||||
{layers(false)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +603,8 @@ export function TimelineView() {
|
||||
<TimelineLegend
|
||||
bucket={data.bucket}
|
||||
hasSeries={data.series.length > 0}
|
||||
labSeries={data.labSeries}
|
||||
label={seriesLabel}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -593,6 +627,7 @@ export function TimelineView() {
|
||||
slot="timeline-grouping"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{valuesMenu(true)}
|
||||
<Button
|
||||
variant={zoom === "range" ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
@@ -616,6 +651,11 @@ export function TimelineView() {
|
||||
onApply={(next) => navigate("range", next)}
|
||||
onClear={zoom === "range" ? () => setZoom("all") : null}
|
||||
/>
|
||||
<LabExcludedNote
|
||||
labSeries={data.labSeries}
|
||||
label={seriesLabel}
|
||||
block
|
||||
/>
|
||||
<TimelineChronicle
|
||||
timeline={{
|
||||
...data,
|
||||
@@ -648,102 +688,3 @@ export function TimelineView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the marks mean. Under the selection bar and set off from it by a
|
||||
* hairline. With value lines, the two marks a line can carry besides its
|
||||
* points are explained by an example of each: the dashed stroke over a short
|
||||
* gap without measurements, and the hollow point of a mean resting on fewer
|
||||
* than three.
|
||||
*/
|
||||
export function TimelineLegend({
|
||||
bucket,
|
||||
hasSeries,
|
||||
}: {
|
||||
bucket: TimelineBucket;
|
||||
hasSeries: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground border-border flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-3 text-xs"
|
||||
data-slot="timeline-legend"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendSpan")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="bg-muted-foreground size-2 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("timeline.legendEvent")}
|
||||
</span>
|
||||
{hasSeries && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-mean"
|
||||
data-bucket={bucket}
|
||||
>
|
||||
<span
|
||||
className="bg-foreground h-0.5 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-gap"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="8"
|
||||
viewBox="0 0 16 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line
|
||||
x1="1"
|
||||
x2="15"
|
||||
y1="4"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="3 3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendGap")}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-slot="timeline-legend-thin"
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="4"
|
||||
cy="4"
|
||||
r="2.75"
|
||||
fill="var(--card)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</svg>
|
||||
{t("timeline.legendThin")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,7 @@ function jsonResponse(body: unknown) {
|
||||
|
||||
function codexClient() {
|
||||
return new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { CodexClient } from "../codex-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
/** A model list that cannot be read: the client walks its static order. */
|
||||
const NO_MODEL_LIST = async () =>
|
||||
({ models: null, from: "live", failure: "network" }) as const;
|
||||
|
||||
/**
|
||||
* Build a `Response` whose `.body` streams a list of SSE event strings.
|
||||
* Mirrors the wire format the Codex backend emits (each event terminated
|
||||
@@ -51,6 +55,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
@@ -97,6 +103,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
@@ -126,6 +134,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "expired-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: async () => {
|
||||
@@ -156,6 +166,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "bad",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: async () => {
|
||||
@@ -183,6 +195,8 @@ describe("CodexClient", () => {
|
||||
|
||||
const onRefresh = vi.fn();
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: async () => {
|
||||
@@ -208,6 +222,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
@@ -245,6 +261,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
@@ -292,6 +310,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
@@ -349,6 +369,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
@@ -393,6 +415,8 @@ describe("CodexClient", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* The Codex client end to end against a stubbed backend: the account's model
|
||||
* list read the way the Codex CLI reads it, the walk built from it, and the
|
||||
* working model kept per sign-in (#1287).
|
||||
*
|
||||
* Every request goes through the real `safeFetch` into a stubbed `fetch`
|
||||
* that answers `/codex/models` and `/codex/responses` per account, so the
|
||||
* list read, the cache around it and the walk are all the production code.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CodexClient } from "../codex-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
import {
|
||||
CODEX_SLUG_CACHE_TTL_MS,
|
||||
clearCodexSlugCache,
|
||||
codexCredentialKey,
|
||||
getCachedCodexSlug,
|
||||
} from "../codex-slug-cache";
|
||||
|
||||
const FIXTURE = JSON.parse(
|
||||
readFileSync(join(__dirname, "fixtures/codex/models-response.json"), "utf8"),
|
||||
) as { models: Array<{ slug: string }> };
|
||||
|
||||
/** The fixture cut to `slugs`, as a plan that offers only those would see it. */
|
||||
function planList(...slugs: string[]): string {
|
||||
return JSON.stringify({
|
||||
models: FIXTURE.models.filter((m) => slugs.includes(m.slug)),
|
||||
});
|
||||
}
|
||||
|
||||
function sse(text = '{"ok":true}'): Response {
|
||||
const body = [
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { usage: { total_tokens: 3 } },
|
||||
})}\n\n`,
|
||||
].join("");
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
function retired(slug: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
detail: `The '${slug}' model is not supported when using Codex with a ChatGPT account.`,
|
||||
error: { type: "invalid_request_error", code: "model_not_found" },
|
||||
}),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
interface Account {
|
||||
/** The `/models` answer, or a function standing in for a failure. */
|
||||
models: string | (() => Response | Promise<Response>);
|
||||
/** The models the responses endpoint accepts for this account. */
|
||||
accepts: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A backend with one entry per ChatGPT account id. Records every request as
|
||||
* `<account> models` or `<account> responses:<slug>`.
|
||||
*/
|
||||
function stubBackend(accounts: Record<string, Account>) {
|
||||
const log: string[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const account = accounts[headers["ChatGPT-Account-ID"]];
|
||||
if (!account) return new Response("unknown account", { status: 401 });
|
||||
const name = headers["ChatGPT-Account-ID"];
|
||||
if (url.includes("/codex/models")) {
|
||||
log.push(`${name} models`);
|
||||
return typeof account.models === "string"
|
||||
? new Response(account.models, { status: 200 })
|
||||
: account.models();
|
||||
}
|
||||
const slug = JSON.parse(String(init.body)).model as string;
|
||||
log.push(`${name} responses:${slug}`);
|
||||
return account.accepts.includes(slug) ? sse() : retired(slug);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return log;
|
||||
}
|
||||
|
||||
function client(userKey: string, accountId: string) {
|
||||
return new CodexClient({
|
||||
accessToken: `token-${accountId}`,
|
||||
accountId,
|
||||
credentialKey: userKey,
|
||||
onTokenRefresh: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const ask = (c: CodexClient) =>
|
||||
c.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
const ENV_KEYS = ["CODEX_MODEL", "CODEX_MODEL_FALLBACK_CHAIN"] as const;
|
||||
let savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
clearCodexSlugCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = savedEnv[k];
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Codex reads the account's live model list", () => {
|
||||
it("goes straight to an offered model after a retirement the built-in order does not know about", async () => {
|
||||
// The 2026-10-14 shape: nothing on the built-in order's head is offered.
|
||||
const log = stubBackend({
|
||||
plus: {
|
||||
models: planList("gpt-6.1-sol", "gpt-6-luna"),
|
||||
accepts: ["gpt-6.1-sol", "gpt-6-luna"],
|
||||
},
|
||||
});
|
||||
const c = client(codexCredentialKey.user("u1"), "plus");
|
||||
await ask(c);
|
||||
expect(log).toEqual(["plus models", "plus responses:gpt-6.1-sol"]);
|
||||
expect(c.getLastDiagnostics()?.attempted).toEqual(["gpt-6.1-sol"]);
|
||||
});
|
||||
|
||||
it("does not read the list again while the working model holds", async () => {
|
||||
const log = stubBackend({
|
||||
plus: { models: planList("gpt-6-sol"), accepts: ["gpt-6-sol"] },
|
||||
});
|
||||
const c = client(codexCredentialKey.user("u1"), "plus");
|
||||
await ask(c);
|
||||
await ask(c);
|
||||
await ask(c);
|
||||
expect(log.filter((l) => l.endsWith("models"))).toHaveLength(1);
|
||||
expect(c.getLastDiagnostics()?.cacheState).toBe("hit");
|
||||
});
|
||||
|
||||
it("a models call that fails still reaches a working model through the static walk", async () => {
|
||||
for (const failure of [
|
||||
() => new Response("bad gateway", { status: 502 }),
|
||||
() => new Response("<html>", { status: 200 }),
|
||||
() => new Response('{"models":[]}', { status: 200 }),
|
||||
() => {
|
||||
throw new TypeError("fetch failed");
|
||||
},
|
||||
]) {
|
||||
clearCodexSlugCache();
|
||||
const log = stubBackend({
|
||||
plus: { models: failure, accepts: ["gpt-6-sol"] },
|
||||
});
|
||||
const c = client(codexCredentialKey.user("u1"), "plus");
|
||||
await ask(c);
|
||||
// Today's walk: terra refused, gpt-6-sol answers.
|
||||
expect(log).toEqual([
|
||||
"plus models",
|
||||
"plus responses:gpt-5.6-terra",
|
||||
"plus responses:gpt-6-sol",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("a models call that never answers is cut off and the walk goes on", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const log = stubBackend({
|
||||
plus: {
|
||||
// Resolves only when the 5 s bound aborts it.
|
||||
models: () =>
|
||||
new Promise<Response>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new DOMException("The operation timed out.", "TimeoutError"),
|
||||
),
|
||||
5_000,
|
||||
),
|
||||
),
|
||||
accepts: ["gpt-5.6-terra"],
|
||||
},
|
||||
});
|
||||
const c = client(codexCredentialKey.user("u1"), "plus");
|
||||
const answer = ask(c);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await answer;
|
||||
expect(log).toEqual(["plus models", "plus responses:gpt-5.6-terra"]);
|
||||
});
|
||||
|
||||
it("an explicit CODEX_MODEL still goes first, ahead of the list", async () => {
|
||||
process.env.CODEX_MODEL = "gpt-6-astra";
|
||||
const log = stubBackend({
|
||||
plus: {
|
||||
models: planList("gpt-6-astra", "gpt-6-sol"),
|
||||
accepts: ["gpt-6-astra", "gpt-6-sol"],
|
||||
},
|
||||
});
|
||||
await ask(client(codexCredentialKey.user("u1"), "plus"));
|
||||
expect(log).toEqual(["plus models", "plus responses:gpt-6-astra"]);
|
||||
});
|
||||
|
||||
it("CODEX_MODEL_FALLBACK_CHAIN replaces the order and the list is not read", async () => {
|
||||
process.env.CODEX_MODEL_FALLBACK_CHAIN = "gpt-6-luna";
|
||||
const log = stubBackend({
|
||||
plus: { models: planList("gpt-6-sol"), accepts: ["gpt-6-luna"] },
|
||||
});
|
||||
await ask(client(codexCredentialKey.user("u1"), "plus"));
|
||||
expect(log).toEqual(["plus responses:gpt-6-luna"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex keeps the working model per sign-in", () => {
|
||||
it("two sign-ins on different plans do not overwrite each other", async () => {
|
||||
const log = stubBackend({
|
||||
// One plan still has terra; the other only the gpt-6 line.
|
||||
pro: {
|
||||
models: planList("gpt-5.6-terra", "gpt-6-sol"),
|
||||
accepts: ["gpt-5.6-terra", "gpt-6-sol"],
|
||||
},
|
||||
edu: {
|
||||
models: planList("gpt-6.1-sol", "gpt-6-luna"),
|
||||
accepts: ["gpt-6.1-sol", "gpt-6-luna"],
|
||||
},
|
||||
});
|
||||
const a = client(codexCredentialKey.user("a"), "pro");
|
||||
const b = client(codexCredentialKey.user("b"), "edu");
|
||||
const central = client(codexCredentialKey.central, "edu");
|
||||
|
||||
await ask(a);
|
||||
await ask(b);
|
||||
await ask(central);
|
||||
await ask(a);
|
||||
await ask(b);
|
||||
|
||||
expect(
|
||||
getCachedCodexSlug({
|
||||
key: codexCredentialKey.user("a"),
|
||||
accountId: "pro",
|
||||
}),
|
||||
).toBe("gpt-5.6-terra");
|
||||
expect(
|
||||
getCachedCodexSlug({
|
||||
key: codexCredentialKey.user("b"),
|
||||
accountId: "edu",
|
||||
}),
|
||||
).toBe("gpt-6.1-sol");
|
||||
expect(
|
||||
getCachedCodexSlug({ key: codexCredentialKey.central, accountId: "edu" }),
|
||||
).toBe("gpt-6.1-sol");
|
||||
// Not one refused request: each sign-in only ever asked what it is offered.
|
||||
expect(log.filter((l) => l.includes("responses"))).toEqual([
|
||||
"pro responses:gpt-5.6-terra",
|
||||
"edu responses:gpt-6.1-sol",
|
||||
"edu responses:gpt-6.1-sol",
|
||||
"pro responses:gpt-5.6-terra",
|
||||
"edu responses:gpt-6.1-sol",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a listed model that answers model_not_found is skipped for that sign-in only", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const both = planList("gpt-5.6-terra", "gpt-6-sol");
|
||||
const log = stubBackend({
|
||||
// The list still offers terra, but the backend already retired it.
|
||||
stale: { models: both, accepts: ["gpt-6-sol"] },
|
||||
fresh: { models: both, accepts: ["gpt-5.6-terra", "gpt-6-sol"] },
|
||||
});
|
||||
const stale = client(codexCredentialKey.user("s"), "stale");
|
||||
const fresh = client(codexCredentialKey.user("f"), "fresh");
|
||||
|
||||
await ask(stale);
|
||||
await ask(fresh);
|
||||
expect(log).toEqual([
|
||||
"stale models",
|
||||
"stale responses:gpt-5.6-terra",
|
||||
"stale responses:gpt-6-sol",
|
||||
"fresh models",
|
||||
"fresh responses:gpt-5.6-terra",
|
||||
]);
|
||||
|
||||
// Past the working model's TTL the stale sign-in walks again, and terra
|
||||
// is at the back of its line even though the list still offers it.
|
||||
log.length = 0;
|
||||
vi.setSystemTime(Date.now() + CODEX_SLUG_CACHE_TTL_MS + 1);
|
||||
await ask(stale);
|
||||
expect(log).toEqual(["stale models", "stale responses:gpt-6-sol"]);
|
||||
});
|
||||
|
||||
it("a credential reconnected to another account tries nothing the old account learned", async () => {
|
||||
const log = stubBackend({
|
||||
pro: {
|
||||
models: planList("gpt-5.6-terra", "gpt-6-sol"),
|
||||
accepts: ["gpt-5.6-terra", "gpt-6-sol"],
|
||||
},
|
||||
// Another plan: terra is neither offered nor accepted.
|
||||
edu: { models: planList("gpt-6.1-sol"), accepts: ["gpt-6.1-sol"] },
|
||||
});
|
||||
const key = codexCredentialKey.user("a");
|
||||
await ask(client(key, "pro"));
|
||||
expect(log).toEqual(["pro models", "pro responses:gpt-5.6-terra"]);
|
||||
|
||||
// The same stored credential, signed in again on the other account, in
|
||||
// a process that has not seen the connect (no write has rebound it).
|
||||
log.length = 0;
|
||||
const again = client(key, "edu");
|
||||
await ask(again);
|
||||
expect(log).toEqual(["edu models", "edu responses:gpt-6.1-sol"]);
|
||||
expect(again.getLastDiagnostics()).toMatchObject({ cacheState: "miss" });
|
||||
});
|
||||
|
||||
it("the working model that stops answering is dropped and the walk goes on", async () => {
|
||||
const accepts = ["gpt-5.6-terra", "gpt-6-sol"];
|
||||
const log = stubBackend({
|
||||
plus: { models: planList("gpt-5.6-terra", "gpt-6-sol"), accepts },
|
||||
});
|
||||
const c = client(codexCredentialKey.user("u1"), "plus");
|
||||
await ask(c);
|
||||
expect(
|
||||
getCachedCodexSlug({
|
||||
key: codexCredentialKey.user("u1"),
|
||||
accountId: "plus",
|
||||
}),
|
||||
).toBe("gpt-5.6-terra");
|
||||
|
||||
// terra retires between two requests.
|
||||
accepts.splice(0, 1);
|
||||
log.length = 0;
|
||||
await ask(c);
|
||||
expect(log).toEqual([
|
||||
"plus responses:gpt-5.6-terra",
|
||||
"plus responses:gpt-6-sol",
|
||||
]);
|
||||
expect(c.getLastDiagnostics()).toMatchObject({
|
||||
cacheState: "hit",
|
||||
attempted: ["gpt-5.6-terra", "gpt-6-sol"],
|
||||
workingSlug: "gpt-6-sol",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* The account's Codex model list: parsing the `/models` body, the bounded
|
||||
* read, and the per-sign-in cache around it.
|
||||
*
|
||||
* The fixture is the upstream catalog bundle (openai/codex
|
||||
* `codex-rs/models-manager/models.json`) cut to the models a ChatGPT
|
||||
* sign-in was offered on 2026-10-24, plus one hidden review model and one
|
||||
* hidden specialist model, every field as upstream ships it except the
|
||||
* instruction templates, which are trimmed. The live endpoint answers in the
|
||||
* same `ModelsResponse` shape (`codex-rs/protocol/src/openai_models.rs`).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const safeFetch = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch: (...a: unknown[]) => safeFetch(...a),
|
||||
}));
|
||||
const emitSignal = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/logging/signal", () => ({
|
||||
emitSignal: (...a: unknown[]) => emitSignal(...a),
|
||||
}));
|
||||
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
import {
|
||||
CODEX_MODELS_CLIENT_VERSION,
|
||||
cachedCodexModels,
|
||||
fetchCodexModelList,
|
||||
parseCodexModelList,
|
||||
readCodexModelCatalog,
|
||||
} from "../codex-model-catalog";
|
||||
import {
|
||||
CODEX_CATALOG_RETRY_MS,
|
||||
CODEX_CATALOG_TTL_MS,
|
||||
clearCodexSlugCache,
|
||||
codexCredentialKey,
|
||||
} from "../codex-slug-cache";
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
join(__dirname, "fixtures/codex/models-response.json"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const AUTH = { accessToken: "access-token", accountId: "acct-plus" };
|
||||
const PERSONAL = { key: codexCredentialKey.user("u1"), accountId: "acct-plus" };
|
||||
|
||||
function jsonResponse(body: string, status = 200): Response {
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetch.mockReset();
|
||||
emitSignal.mockReset();
|
||||
clearCodexSlugCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("parseCodexModelList", () => {
|
||||
it("reads the upstream shape: slug, visibility, priority, modalities, specialty", () => {
|
||||
const models = parseCodexModelList(JSON.parse(FIXTURE));
|
||||
expect(models?.map((m) => m.slug)).toEqual([
|
||||
"gpt-6-astra",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-daybreak-blue-latest",
|
||||
"codex-auto-review",
|
||||
]);
|
||||
const luna = models?.find((m) => m.slug === "gpt-6-luna");
|
||||
expect(luna).toEqual({
|
||||
slug: "gpt-6-luna",
|
||||
listed: true,
|
||||
priority: 4,
|
||||
inputModalities: ["text", "image"],
|
||||
specialty: null,
|
||||
});
|
||||
expect(models?.find((m) => m.slug === "codex-auto-review")?.listed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
models?.find((m) => m.slug === "gpt-daybreak-blue-latest")?.specialty,
|
||||
).toBe("cyber");
|
||||
});
|
||||
|
||||
it("refuses a body that is not the documented envelope", () => {
|
||||
for (const body of [
|
||||
null,
|
||||
"models",
|
||||
42,
|
||||
[],
|
||||
{},
|
||||
{ models: "gpt-6-sol" },
|
||||
{ data: [{ id: "gpt-6-sol" }] },
|
||||
]) {
|
||||
expect(parseCodexModelList(body), JSON.stringify(body)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips entries without a usable slug and keeps the rest", () => {
|
||||
const models = parseCodexModelList({
|
||||
models: [
|
||||
null,
|
||||
"gpt-6-sol",
|
||||
{ slug: 7 },
|
||||
{ slug: "" },
|
||||
{ slug: "has spaces" },
|
||||
{ slug: "gpt-6-sol\nInjected: header" },
|
||||
{ slug: "x".repeat(200) },
|
||||
{ slug: "gpt-6-luna", visibility: "list" },
|
||||
{ slug: "gpt-6-luna", visibility: "list" },
|
||||
],
|
||||
});
|
||||
expect(models).toEqual([
|
||||
{
|
||||
slug: "gpt-6-luna",
|
||||
listed: true,
|
||||
priority: null,
|
||||
inputModalities: null,
|
||||
specialty: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads missing or odd fields conservatively", () => {
|
||||
const [model] = parseCodexModelList({
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-7-sol",
|
||||
visibility: "LIST",
|
||||
priority: "1",
|
||||
input_modalities: "text",
|
||||
},
|
||||
],
|
||||
})!;
|
||||
// Not "list" exactly → not listed; a non-array modality field says nothing.
|
||||
expect(model).toEqual({
|
||||
slug: "gpt-7-sol",
|
||||
listed: false,
|
||||
priority: null,
|
||||
inputModalities: null,
|
||||
specialty: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty list", () => {
|
||||
expect(parseCodexModelList({ models: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCodexModelList", () => {
|
||||
it("asks the endpoint the Codex CLI asks, with the responses call's sign-in", async () => {
|
||||
safeFetch.mockResolvedValue(jsonResponse(FIXTURE));
|
||||
const result = await fetchCodexModelList(AUTH);
|
||||
expect(result.ok).toBe(true);
|
||||
const [url, init, options] = safeFetch.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
`https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_MODELS_CLIENT_VERSION}`,
|
||||
);
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers.Authorization).toBe("Bearer access-token");
|
||||
expect(init.headers["ChatGPT-Account-ID"]).toBe("acct-plus");
|
||||
expect(init.headers.originator).toBe("healthlog");
|
||||
// Bounded: the Codex CLI's own 5 s, and no redirect following.
|
||||
expect(options).toEqual({ timeoutMs: 5_000 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a timeout", new SafeFetchError("timed out", "timeout"), "timeout"],
|
||||
["a network error", new SafeFetchError("reset", "network"), "network"],
|
||||
])("maps %s to its reason", async (_label, error, reason) => {
|
||||
safeFetch.mockRejectedValue(error);
|
||||
expect(await fetchCodexModelList(AUTH)).toEqual({
|
||||
ok: false,
|
||||
reason,
|
||||
status: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps refusals, bad bodies and empty lists to closed reasons", async () => {
|
||||
const cases: Array<[Response, string]> = [
|
||||
[jsonResponse("unauthorized", 401), "auth"],
|
||||
[jsonResponse("forbidden", 403), "auth"],
|
||||
[jsonResponse("bad gateway", 502), "http_error"],
|
||||
[new Response(null, { status: 302 }), "http_error"],
|
||||
[jsonResponse("<html>not json</html>"), "unparsable"],
|
||||
[jsonResponse('{"data":[]}'), "unparsable"],
|
||||
[jsonResponse('{"models":[]}'), "empty"],
|
||||
[jsonResponse('{"models":[{"slug":""}]}'), "empty"],
|
||||
];
|
||||
for (const [response, reason] of cases) {
|
||||
safeFetch.mockResolvedValueOnce(response);
|
||||
const result = await fetchCodexModelList(AUTH);
|
||||
expect(result.ok, reason).toBe(false);
|
||||
expect(!result.ok && result.reason, reason).toBe(reason);
|
||||
}
|
||||
});
|
||||
|
||||
it("stops reading a body past the size bound", async () => {
|
||||
const huge = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const chunk = new Uint8Array(1024 * 1024).fill(32);
|
||||
for (let i = 0; i < 5; i += 1) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
safeFetch.mockResolvedValue(huge);
|
||||
expect(await fetchCodexModelList(AUTH)).toEqual({
|
||||
ok: false,
|
||||
reason: "too_large",
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a body that stalls past the timeout as a timeout", async () => {
|
||||
const stalled = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull() {
|
||||
throw new DOMException("The operation timed out.", "TimeoutError");
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
safeFetch.mockResolvedValue(stalled);
|
||||
const result = await fetchCodexModelList(AUTH);
|
||||
expect(!result.ok && result.reason).toBe("timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCodexModelCatalog", () => {
|
||||
it("reads once, then answers from the cache until the TTL passes", async () => {
|
||||
vi.useFakeTimers();
|
||||
safeFetch.mockImplementation(async () => jsonResponse(FIXTURE));
|
||||
|
||||
const first = await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
expect(first.from).toBe("live");
|
||||
expect(first.models).toHaveLength(9);
|
||||
const second = await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
expect(second.from).toBe("cache");
|
||||
expect(safeFetch).toHaveBeenCalledTimes(1);
|
||||
expect(cachedCodexModels(PERSONAL)).toHaveLength(9);
|
||||
|
||||
vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1);
|
||||
expect((await readCodexModelCatalog(PERSONAL, AUTH)).from).toBe("live");
|
||||
expect(safeFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shares one read between concurrent requests on a sign-in", async () => {
|
||||
let release: (r: Response) => void = () => {};
|
||||
safeFetch.mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const reads = [
|
||||
readCodexModelCatalog(PERSONAL, AUTH),
|
||||
readCodexModelCatalog(PERSONAL, AUTH),
|
||||
readCodexModelCatalog(PERSONAL, AUTH),
|
||||
];
|
||||
release(jsonResponse(FIXTURE));
|
||||
const results = await Promise.all(reads);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(1);
|
||||
expect(results.every((r) => r.models?.length === 9)).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers a failed read for the retry window and signals it once", async () => {
|
||||
vi.useFakeTimers();
|
||||
safeFetch.mockImplementation(async () => jsonResponse("down", 503));
|
||||
|
||||
const first = await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
expect(first).toEqual({
|
||||
models: null,
|
||||
from: "live",
|
||||
failure: "http_error",
|
||||
});
|
||||
const again = await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
expect(again).toEqual({
|
||||
models: null,
|
||||
from: "cache",
|
||||
failure: "http_error",
|
||||
});
|
||||
expect(safeFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Past the retry window the list is asked again; still failing, the
|
||||
// stretch has already been reported.
|
||||
vi.advanceTimersByTime(CODEX_CATALOG_RETRY_MS + 1);
|
||||
await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
expect(safeFetch).toHaveBeenCalledTimes(2);
|
||||
expect(emitSignal).toHaveBeenCalledTimes(1);
|
||||
expect(emitSignal).toHaveBeenCalledWith({
|
||||
action: "ai.codex.models_unreadable",
|
||||
level: "warn",
|
||||
meta: { connection: "personal", reason: "http_error", status: 503 },
|
||||
});
|
||||
});
|
||||
|
||||
it("signals again after the list answered in between", async () => {
|
||||
vi.useFakeTimers();
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(jsonResponse("down", 503))
|
||||
.mockResolvedValueOnce(jsonResponse(FIXTURE))
|
||||
.mockResolvedValueOnce(new Response("{", { status: 200 }));
|
||||
await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
vi.advanceTimersByTime(CODEX_CATALOG_RETRY_MS + 1);
|
||||
await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
vi.advanceTimersByTime(CODEX_CATALOG_TTL_MS + 1);
|
||||
await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
expect(emitSignal).toHaveBeenCalledTimes(2);
|
||||
expect(emitSignal.mock.calls[1][0].meta.reason).toBe("unparsable");
|
||||
});
|
||||
|
||||
it("signals once per sign-in, naming which connection without naming whose", async () => {
|
||||
safeFetch.mockImplementation(async () => {
|
||||
throw new SafeFetchError("timed out", "timeout");
|
||||
});
|
||||
const central = { key: codexCredentialKey.central, accountId: "acct-op" };
|
||||
await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
await readCodexModelCatalog(central, { ...AUTH, accountId: "acct-op" });
|
||||
await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
await readCodexModelCatalog(central, { ...AUTH, accountId: "acct-op" });
|
||||
expect(emitSignal.mock.calls.map((c) => c[0].meta)).toEqual([
|
||||
{ connection: "personal", reason: "timeout", status: null },
|
||||
{ connection: "central", reason: "timeout", status: null },
|
||||
]);
|
||||
expect(JSON.stringify(emitSignal.mock.calls)).not.toContain("u1");
|
||||
});
|
||||
|
||||
it("does not remember an expired token, so the next request reads again", async () => {
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(jsonResponse("unauthorized", 401))
|
||||
.mockResolvedValueOnce(jsonResponse(FIXTURE));
|
||||
const refused = await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
expect(refused.failure).toBe("auth");
|
||||
const next = await readCodexModelCatalog(PERSONAL, {
|
||||
...AUTH,
|
||||
accessToken: "refreshed",
|
||||
});
|
||||
expect(next.models).toHaveLength(9);
|
||||
expect(emitSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps each sign-in's list to itself", async () => {
|
||||
const plus = JSON.parse(FIXTURE) as { models: { slug: string }[] };
|
||||
const other = {
|
||||
models: plus.models.filter((m) => m.slug.startsWith("gpt-6")),
|
||||
};
|
||||
safeFetch
|
||||
.mockResolvedValueOnce(jsonResponse(FIXTURE))
|
||||
.mockResolvedValueOnce(jsonResponse(JSON.stringify(other)));
|
||||
const second = { key: codexCredentialKey.user("u2"), accountId: "acct-b" };
|
||||
await readCodexModelCatalog(PERSONAL, AUTH);
|
||||
await readCodexModelCatalog(second, { ...AUTH, accountId: "acct-b" });
|
||||
expect(cachedCodexModels(PERSONAL)).toHaveLength(9);
|
||||
expect(cachedCodexModels(second)?.map((m) => m.slug)).toEqual([
|
||||
"gpt-6-astra",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ function errorResponse(status: number, body: string): Response {
|
||||
|
||||
function client() {
|
||||
return new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
@@ -158,6 +159,7 @@ describe("Codex reasoning request", () => {
|
||||
);
|
||||
vi.stubGlobal("fetch", mock);
|
||||
const c = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The routes that connect or disconnect a stored Codex credential tell the
|
||||
* per-sign-in memory, so the vision gate, which holds no account id, stops
|
||||
* reading what the previous sign-in learned (`codex-slug-cache.ts`). The
|
||||
* account-checked client reads cover every other path; this file holds the
|
||||
* four call sites.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
describe("Codex sign-in routes", () => {
|
||||
it("tells the sign-in memory about every connect and disconnect", () => {
|
||||
const routes: Array<[string, RegExp]> = [
|
||||
[
|
||||
"src/app/api/auth/codex/device-poll/route.ts",
|
||||
/startCodexSignIn\(\{\s*key: codexCredentialKey\.user\(user\.id\),\s*accountId: result\.creds\.accountId/,
|
||||
],
|
||||
[
|
||||
"src/app/api/admin/central-codex/device-poll/route.ts",
|
||||
/startCodexSignIn\(\{\s*key: codexCredentialKey\.central,\s*accountId: result\.creds\.accountId/,
|
||||
],
|
||||
[
|
||||
"src/app/api/auth/codex/disconnect/route.ts",
|
||||
/forgetCodexSignIn\(codexCredentialKey\.user\(user\.id\)\)/,
|
||||
],
|
||||
[
|
||||
"src/app/api/admin/central-codex/route.ts",
|
||||
/forgetCodexSignIn\(codexCredentialKey\.central\)/,
|
||||
],
|
||||
];
|
||||
for (const [file, call] of routes) {
|
||||
expect(readSource(file, call), file).toMatch(call);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,59 +1,195 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
getCachedCodexSlug,
|
||||
setCachedCodexSlug,
|
||||
invalidateCachedCodexSlug,
|
||||
clearCodexSlugCache,
|
||||
CODEX_CATALOG_RETRY_MS,
|
||||
CODEX_CATALOG_TTL_MS,
|
||||
CODEX_CREDENTIAL_CACHE_MAX,
|
||||
CODEX_REJECTED_SLUG_TTL_MS,
|
||||
CODEX_SLUG_CACHE_TTL_MS,
|
||||
clearCodexSlugCache,
|
||||
codexCredentialCacheSize,
|
||||
codexCredentialKey,
|
||||
codexGateMemory,
|
||||
forgetCodexSignIn,
|
||||
getCachedCodexSlug,
|
||||
getCodexCatalogState,
|
||||
rejectedCodexSlugs,
|
||||
rememberRejectedCodexSlug,
|
||||
setCachedCodexSlug,
|
||||
setCodexCatalogState,
|
||||
startCodexSignIn,
|
||||
} from "../codex-slug-cache";
|
||||
|
||||
/**
|
||||
* Slug-drift defence positive cache.
|
||||
* Cache TTL = 1 hour per `docs/codex-protocol-spec.md` §7b.
|
||||
* Per-sign-in memory: the working slug (1 h, spec §7b), refused slugs, and
|
||||
* the account's model list, each kept under its stored credential.
|
||||
*/
|
||||
describe("Codex slug cache", () => {
|
||||
const A = { key: codexCredentialKey.user("a"), accountId: "acct-a" };
|
||||
const B = { key: codexCredentialKey.user("b"), accountId: "acct-b" };
|
||||
const CENTRAL = { key: codexCredentialKey.central, accountId: "acct-op" };
|
||||
|
||||
describe("Codex per-sign-in cache", () => {
|
||||
beforeEach(() => {
|
||||
clearCodexSlugCache();
|
||||
});
|
||||
|
||||
it("keys a person's connection and the central one apart", () => {
|
||||
expect(codexCredentialKey.user("a")).toBe("user:a");
|
||||
expect(codexCredentialKey.central).toBe("central");
|
||||
});
|
||||
|
||||
it("starts empty", () => {
|
||||
expect(getCachedCodexSlug()).toBeNull();
|
||||
expect(getCachedCodexSlug(A)).toBeNull();
|
||||
expect(rejectedCodexSlugs(A).size).toBe(0);
|
||||
expect(getCodexCatalogState(A)).toBeNull();
|
||||
});
|
||||
|
||||
it("set + get returns the cached slug within TTL", () => {
|
||||
setCachedCodexSlug("gpt-5.3-codex", 1_000);
|
||||
expect(getCachedCodexSlug(1_000)).toBe("gpt-5.3-codex");
|
||||
expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS - 1)).toBe(
|
||||
"gpt-5.3-codex",
|
||||
it("holds the working slug for exactly the TTL", () => {
|
||||
setCachedCodexSlug(A, "gpt-6-sol", 1_000);
|
||||
expect(getCachedCodexSlug(A, 1_000)).toBe("gpt-6-sol");
|
||||
expect(getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe(
|
||||
"gpt-6-sol",
|
||||
);
|
||||
expect(
|
||||
getCachedCodexSlug(A, 1_000 + CODEX_SLUG_CACHE_TTL_MS + 1),
|
||||
).toBeNull();
|
||||
expect(CODEX_SLUG_CACHE_TTL_MS).toBe(60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("expires entries strictly after the TTL", () => {
|
||||
setCachedCodexSlug("gpt-5.3-codex", 1_000);
|
||||
// Exactly at TTL: still valid (the comparison is `now - cached > TTL`).
|
||||
expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS)).toBe(
|
||||
"gpt-5.3-codex",
|
||||
it("keeps every sign-in's working slug to itself", () => {
|
||||
// Two plans: one account works on terra, the other only on gpt-6.
|
||||
setCachedCodexSlug(A, "gpt-5.6-terra");
|
||||
setCachedCodexSlug(B, "gpt-6.1-sol");
|
||||
setCachedCodexSlug(CENTRAL, "gpt-6-sol");
|
||||
expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra");
|
||||
expect(getCachedCodexSlug(B)).toBe("gpt-6.1-sol");
|
||||
expect(getCachedCodexSlug(CENTRAL)).toBe("gpt-6-sol");
|
||||
|
||||
rememberRejectedCodexSlug(B, "gpt-6.1-sol");
|
||||
expect(getCachedCodexSlug(B)).toBeNull();
|
||||
expect(getCachedCodexSlug(A)).toBe("gpt-5.6-terra");
|
||||
expect(rejectedCodexSlugs(A).size).toBe(0);
|
||||
});
|
||||
|
||||
it("a refusal drops the working slot and is held for its own TTL", () => {
|
||||
setCachedCodexSlug(A, "gpt-5.6-terra", 0);
|
||||
rememberRejectedCodexSlug(A, "gpt-5.6-terra", 10);
|
||||
expect(getCachedCodexSlug(A, 10)).toBeNull();
|
||||
expect([...rejectedCodexSlugs(A, 10)]).toEqual(["gpt-5.6-terra"]);
|
||||
expect([...rejectedCodexSlugs(A, 10 + CODEX_REJECTED_SLUG_TTL_MS)]).toEqual(
|
||||
["gpt-5.6-terra"],
|
||||
);
|
||||
// Past TTL: gone.
|
||||
expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1)).toBeNull();
|
||||
expect(
|
||||
rejectedCodexSlugs(A, 10 + CODEX_REJECTED_SLUG_TTL_MS + 1).size,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("expired entry is evicted on read", () => {
|
||||
setCachedCodexSlug("gpt-5.3-codex", 1_000);
|
||||
getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1);
|
||||
// Subsequent get should be null too (no resurrection).
|
||||
expect(getCachedCodexSlug(1_000 + CODEX_SLUG_CACHE_TTL_MS + 1)).toBeNull();
|
||||
it("a slug that works again leaves the refused set", () => {
|
||||
rememberRejectedCodexSlug(A, "gpt-6-sol");
|
||||
setCachedCodexSlug(A, "gpt-6-sol");
|
||||
expect(rejectedCodexSlugs(A).size).toBe(0);
|
||||
});
|
||||
|
||||
it("invalidate clears the slot", () => {
|
||||
setCachedCodexSlug("gpt-5.3-codex");
|
||||
invalidateCachedCodexSlug();
|
||||
expect(getCachedCodexSlug()).toBeNull();
|
||||
it("a credential reconnected to another account reads as empty before anything is written", () => {
|
||||
setCachedCodexSlug(A, "gpt-5.6-terra");
|
||||
rememberRejectedCodexSlug(A, "gpt-6-sol");
|
||||
setCodexCatalogState(A, { kind: "offered", models: [], at: Date.now() });
|
||||
|
||||
// Same stored credential, a different ChatGPT account (another plan):
|
||||
// its first read finds nothing the old account learned.
|
||||
const other = { key: A.key, accountId: "acct-other" };
|
||||
expect(getCachedCodexSlug(other)).toBeNull();
|
||||
expect(rejectedCodexSlugs(other).size).toBe(0);
|
||||
expect(getCodexCatalogState(other)).toBeNull();
|
||||
|
||||
// Its first write starts the entry afresh, and the old account's memory
|
||||
// is gone with it.
|
||||
setCachedCodexSlug(other, "gpt-6-luna");
|
||||
expect(getCachedCodexSlug(other)).toBe("gpt-6-luna");
|
||||
expect(rejectedCodexSlugs(other).size).toBe(0);
|
||||
expect(getCachedCodexSlug(A)).toBeNull();
|
||||
expect(getCodexCatalogState(A)).toBeNull();
|
||||
});
|
||||
|
||||
it("set replaces the previous slot", () => {
|
||||
setCachedCodexSlug("a", 1_000);
|
||||
setCachedCodexSlug("b", 2_000);
|
||||
expect(getCachedCodexSlug(2_500)).toBe("b");
|
||||
it("a connect rebinds the gate's memory and a disconnect forgets it", () => {
|
||||
setCachedCodexSlug(A, "gpt-5.6-terra");
|
||||
rememberRejectedCodexSlug(A, "gpt-6-sol");
|
||||
expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra");
|
||||
|
||||
// A reconnect to the same account keeps what it learned.
|
||||
startCodexSignIn(A);
|
||||
expect(codexGateMemory(A.key).working).toBe("gpt-5.6-terra");
|
||||
|
||||
// A reconnect to another account starts it afresh.
|
||||
startCodexSignIn({ key: A.key, accountId: "acct-other" });
|
||||
expect(codexGateMemory(A.key)).toEqual({
|
||||
working: null,
|
||||
rejected: new Set(),
|
||||
catalog: null,
|
||||
});
|
||||
|
||||
setCachedCodexSlug(B, "gpt-6-sol");
|
||||
forgetCodexSignIn(B.key);
|
||||
expect(codexGateMemory(B.key).working).toBeNull();
|
||||
expect(getCachedCodexSlug(B)).toBeNull();
|
||||
});
|
||||
|
||||
it("holds a list for its TTL and a failed read for the shorter retry window", () => {
|
||||
setCodexCatalogState(A, { kind: "offered", models: [], at: 0 });
|
||||
expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS)?.kind).toBe("offered");
|
||||
expect(getCodexCatalogState(A, CODEX_CATALOG_TTL_MS + 1)).toBeNull();
|
||||
|
||||
setCodexCatalogState(B, { kind: "unreadable", reason: "timeout", at: 0 });
|
||||
expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS)?.kind).toBe(
|
||||
"unreadable",
|
||||
);
|
||||
expect(getCodexCatalogState(B, CODEX_CATALOG_RETRY_MS + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the first unreadable read of a stretch, and re-arms on success", () => {
|
||||
const unreadable = {
|
||||
kind: "unreadable" as const,
|
||||
reason: "timeout" as const,
|
||||
at: 0,
|
||||
};
|
||||
expect(setCodexCatalogState(A, unreadable)).toBe(true);
|
||||
expect(setCodexCatalogState(A, unreadable)).toBe(false);
|
||||
expect(setCodexCatalogState(B, unreadable)).toBe(true);
|
||||
expect(
|
||||
setCodexCatalogState(A, { kind: "offered", models: [], at: 0 }),
|
||||
).toBe(false);
|
||||
expect(setCodexCatalogState(A, unreadable)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds at most the cap, dropping the least recently used sign-in", () => {
|
||||
for (let i = 0; i < CODEX_CREDENTIAL_CACHE_MAX; i += 1) {
|
||||
setCachedCodexSlug(
|
||||
{ key: codexCredentialKey.user(`u${i}`), accountId: `acct-${i}` },
|
||||
"gpt-6-sol",
|
||||
);
|
||||
}
|
||||
// Touch the oldest so the second-oldest is the one to go.
|
||||
expect(
|
||||
getCachedCodexSlug({
|
||||
key: codexCredentialKey.user("u0"),
|
||||
accountId: "acct-0",
|
||||
}),
|
||||
).toBe("gpt-6-sol");
|
||||
setCachedCodexSlug(
|
||||
{ key: codexCredentialKey.user("new"), accountId: "acct-new" },
|
||||
"gpt-6-sol",
|
||||
);
|
||||
expect(codexCredentialCacheSize()).toBe(CODEX_CREDENTIAL_CACHE_MAX);
|
||||
expect(
|
||||
getCachedCodexSlug({
|
||||
key: codexCredentialKey.user("u0"),
|
||||
accountId: "acct-0",
|
||||
}),
|
||||
).toBe("gpt-6-sol");
|
||||
expect(
|
||||
getCachedCodexSlug({
|
||||
key: codexCredentialKey.user("u1"),
|
||||
accountId: "acct-1",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* The order a Codex sign-in walks, built from the operator's choice, our
|
||||
* preference order and the models the account is offered; and the vision
|
||||
* answer the gates read from it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const annotate = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate }));
|
||||
|
||||
import {
|
||||
parseCodexModelList,
|
||||
type CodexCatalogRead,
|
||||
type CodexOfferedModel,
|
||||
} from "../codex-model-catalog";
|
||||
import {
|
||||
DEFAULT_SLUG_FALLBACK_CHAIN,
|
||||
buildCodexSlugChain,
|
||||
chainEntryReadsImages,
|
||||
codexSignInReadsImages,
|
||||
loadCodexAttemptChain,
|
||||
resolveCodexVisionSlug,
|
||||
} from "../codex-slug-chain";
|
||||
import {
|
||||
clearCodexSlugCache,
|
||||
codexCredentialKey,
|
||||
forgetCodexSignIn,
|
||||
rememberRejectedCodexSlug,
|
||||
setCachedCodexSlug,
|
||||
setCodexCatalogState,
|
||||
startCodexSignIn,
|
||||
} from "../codex-slug-cache";
|
||||
|
||||
const OFFERED = parseCodexModelList(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, "fixtures/codex/models-response.json"),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
)!;
|
||||
|
||||
function offered(...slugs: string[]): CodexOfferedModel[] {
|
||||
return slugs.map((slug, i) => ({
|
||||
slug,
|
||||
listed: true,
|
||||
priority: i,
|
||||
inputModalities: ["text", "image"],
|
||||
specialty: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const ENV_KEYS = ["CODEX_MODEL", "CODEX_MODEL_FALLBACK_CHAIN"] as const;
|
||||
let savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
clearCodexSlugCache();
|
||||
annotate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = savedEnv[k];
|
||||
}
|
||||
});
|
||||
|
||||
describe("buildCodexSlugChain", () => {
|
||||
it("without a list, walks the static preference order", () => {
|
||||
for (const list of [null, []]) {
|
||||
expect(buildCodexSlugChain({ offered: list })).toEqual({
|
||||
chain: [...DEFAULT_SLUG_FALLBACK_CHAIN],
|
||||
source: "static",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("with the live list: preference order kept to what is offered, then the rest, then the static tail", () => {
|
||||
const { chain, source } = buildCodexSlugChain({ offered: OFFERED });
|
||||
expect(source).toBe("live");
|
||||
expect(chain).toEqual([
|
||||
// Our preference order, kept to what the account is offered.
|
||||
"gpt-5.6-terra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.6-sol",
|
||||
// Not offered (retired): only reached when everything above failed.
|
||||
"gpt-5.5",
|
||||
]);
|
||||
// Never offered to the walk: the frontier tier, the hidden review model,
|
||||
// the hidden specialist line.
|
||||
for (const skipped of [
|
||||
"gpt-6-astra",
|
||||
"codex-auto-review",
|
||||
"gpt-daybreak-blue-latest",
|
||||
]) {
|
||||
expect(chain).not.toContain(skipped);
|
||||
}
|
||||
});
|
||||
|
||||
it("follows an account onto a model our order does not know yet, in the list's priority order", () => {
|
||||
// A later retirement: the gpt-5.6 line gone, two new models offered.
|
||||
const list: CodexOfferedModel[] = [
|
||||
{ ...offered("gpt-7-luna")[0], priority: 9 },
|
||||
{ ...offered("gpt-7-sol")[0], priority: 2 },
|
||||
{ ...offered("gpt-6-luna")[0], priority: 5 },
|
||||
{ ...offered("gpt-7-astra")[0], priority: 1 },
|
||||
{ ...offered("gpt-7-mystery")[0], priority: null },
|
||||
];
|
||||
expect(buildCodexSlugChain({ offered: list }).chain).toEqual([
|
||||
"gpt-6-luna",
|
||||
"gpt-7-sol",
|
||||
"gpt-7-luna",
|
||||
"gpt-7-mystery",
|
||||
// the static tail
|
||||
"gpt-5.6-terra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not filter on supported_in_api, which describes API-key access", () => {
|
||||
const model = parseCodexModelList({
|
||||
models: [
|
||||
{ slug: "gpt-7-sol", visibility: "list", supported_in_api: false },
|
||||
],
|
||||
})!;
|
||||
expect(buildCodexSlugChain({ offered: model }).chain[0]).toBe("gpt-7-sol");
|
||||
});
|
||||
|
||||
it("CODEX_MODEL goes first, ahead of the live order", () => {
|
||||
process.env.CODEX_MODEL = "gpt-6-astra";
|
||||
const { chain, source } = buildCodexSlugChain({ offered: OFFERED });
|
||||
expect(source).toBe("live");
|
||||
expect(chain[0]).toBe("gpt-6-astra");
|
||||
expect(chain[1]).toBe("gpt-5.6-terra");
|
||||
expect(new Set(chain).size).toBe(chain.length);
|
||||
});
|
||||
|
||||
it("CODEX_MODEL_FALLBACK_CHAIN replaces the order outright and the list is ignored", () => {
|
||||
process.env.CODEX_MODEL = "pinned";
|
||||
process.env.CODEX_MODEL_FALLBACK_CHAIN = " a , b,,pinned ";
|
||||
expect(buildCodexSlugChain({ offered: OFFERED })).toEqual({
|
||||
chain: ["pinned", "a", "b"],
|
||||
source: "operator",
|
||||
});
|
||||
});
|
||||
|
||||
it("an empty CODEX_MODEL_FALLBACK_CHAIN does not replace anything", () => {
|
||||
process.env.CODEX_MODEL_FALLBACK_CHAIN = " , ";
|
||||
expect(buildCodexSlugChain({ offered: null }).chain).toEqual([
|
||||
...DEFAULT_SLUG_FALLBACK_CHAIN,
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves slugs this sign-in was refused to the very end, never out", () => {
|
||||
const { chain } = buildCodexSlugChain({
|
||||
offered: OFFERED,
|
||||
rejected: new Set(["gpt-5.6-terra", "gpt-6-sol"]),
|
||||
});
|
||||
expect(chain.slice(0, 2)).toEqual(["gpt-6.1-sol", "gpt-6-luna"]);
|
||||
expect(chain.slice(-2)).toEqual(["gpt-5.6-terra", "gpt-6-sol"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadCodexAttemptChain", () => {
|
||||
const credential = { key: codexCredentialKey.user("u1"), accountId: "acct" };
|
||||
|
||||
it("reads the list for the sign-in and annotates the outcome", async () => {
|
||||
const readModels = vi.fn(async (): Promise<CodexCatalogRead> => ({
|
||||
models: offered("gpt-6.1-sol"),
|
||||
from: "live",
|
||||
failure: null,
|
||||
}));
|
||||
const chain = await loadCodexAttemptChain({
|
||||
credential,
|
||||
accessToken: "tok",
|
||||
readModels,
|
||||
});
|
||||
expect(chain[0]).toBe("gpt-6.1-sol");
|
||||
expect(readModels).toHaveBeenCalledWith(credential, {
|
||||
accessToken: "tok",
|
||||
accountId: "acct",
|
||||
});
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
"codex.models.chain": "live",
|
||||
"codex.models.read": "live",
|
||||
"codex.models.failure": null,
|
||||
"codex.models.offered": 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the static walk when the read fails, or throws", async () => {
|
||||
const failing = vi.fn(async (): Promise<CodexCatalogRead> => ({
|
||||
models: null,
|
||||
from: "live",
|
||||
failure: "timeout",
|
||||
}));
|
||||
expect(
|
||||
await loadCodexAttemptChain({
|
||||
credential,
|
||||
accessToken: "t",
|
||||
readModels: failing,
|
||||
}),
|
||||
).toEqual([...DEFAULT_SLUG_FALLBACK_CHAIN]);
|
||||
expect(annotate.mock.calls[0][0].meta).toEqual({
|
||||
"codex.models.chain": "static",
|
||||
"codex.models.read": "live",
|
||||
"codex.models.failure": "timeout",
|
||||
"codex.models.offered": 0,
|
||||
});
|
||||
|
||||
const throwing = vi.fn(async (): Promise<CodexCatalogRead> => {
|
||||
throw new Error("bug");
|
||||
});
|
||||
expect(
|
||||
await loadCodexAttemptChain({
|
||||
credential,
|
||||
accessToken: "t",
|
||||
readModels: throwing,
|
||||
}),
|
||||
).toEqual([...DEFAULT_SLUG_FALLBACK_CHAIN]);
|
||||
});
|
||||
|
||||
it("does not read the list when the operator replaced the chain", async () => {
|
||||
process.env.CODEX_MODEL_FALLBACK_CHAIN = "x,y";
|
||||
const readModels = vi.fn();
|
||||
expect(
|
||||
await loadCodexAttemptChain({ credential, accessToken: "t", readModels }),
|
||||
).toEqual(["x", "y"]);
|
||||
expect(readModels).not.toHaveBeenCalled();
|
||||
expect(annotate.mock.calls[0][0].meta["codex.models.read"]).toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("vision for a Codex sign-in", () => {
|
||||
const A = { key: codexCredentialKey.user("a"), accountId: "acct-a" };
|
||||
const B = { key: codexCredentialKey.user("b"), accountId: "acct-b" };
|
||||
|
||||
it("reads the sign-in's own working slug, else the head of its own order", () => {
|
||||
setCodexCatalogState(B, {
|
||||
kind: "offered",
|
||||
models: offered("gpt-6-luna"),
|
||||
at: Date.now(),
|
||||
});
|
||||
setCachedCodexSlug(A, "gpt-6.1-sol");
|
||||
expect(resolveCodexVisionSlug(A.key)).toBe("gpt-6.1-sol");
|
||||
expect(resolveCodexVisionSlug(B.key)).toBe("gpt-6-luna");
|
||||
expect(resolveCodexVisionSlug(codexCredentialKey.central)).toBe(
|
||||
"gpt-5.6-terra",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the list's input modalities over the model family", () => {
|
||||
// A family the gates know as vision-capable, listed as text-only.
|
||||
setCodexCatalogState(A, {
|
||||
kind: "offered",
|
||||
models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }],
|
||||
at: Date.now(),
|
||||
});
|
||||
expect(codexSignInReadsImages(A.key)).toBe(false);
|
||||
|
||||
// A text-only family by name, listed with image input.
|
||||
setCodexCatalogState(B, {
|
||||
kind: "offered",
|
||||
models: offered("o3-mini"),
|
||||
at: Date.now(),
|
||||
});
|
||||
expect(codexSignInReadsImages(B.key)).toBe(true);
|
||||
});
|
||||
|
||||
it("answers from the new sign-in as soon as the credential is connected to another account", () => {
|
||||
// The old account: its working model listed as text-only.
|
||||
setCodexCatalogState(A, {
|
||||
kind: "offered",
|
||||
models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }],
|
||||
at: Date.now(),
|
||||
});
|
||||
setCachedCodexSlug(A, "gpt-6-sol");
|
||||
expect(codexSignInReadsImages(A.key)).toBe(false);
|
||||
|
||||
// What the device-poll route records on a reconnect.
|
||||
startCodexSignIn({ key: A.key, accountId: "acct-new" });
|
||||
expect(resolveCodexVisionSlug(A.key)).toBe(DEFAULT_SLUG_FALLBACK_CHAIN[0]);
|
||||
expect(codexSignInReadsImages(A.key)).toBe(true);
|
||||
expect(chainEntryReadsImages("codex", "a", null)).toBe(true);
|
||||
|
||||
// And on a disconnect.
|
||||
setCachedCodexSlug(B, "o3-mini");
|
||||
expect(codexSignInReadsImages(B.key)).toBe(false);
|
||||
forgetCodexSignIn(B.key);
|
||||
expect(codexSignInReadsImages(B.key)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the model family when the list says nothing", () => {
|
||||
setCodexCatalogState(A, {
|
||||
kind: "offered",
|
||||
// A text-only family the gates know, listed without modalities.
|
||||
models: [{ ...offered("o3-mini")[0], inputModalities: null }],
|
||||
at: Date.now(),
|
||||
});
|
||||
expect(codexSignInReadsImages(A.key)).toBe(false);
|
||||
// No list at all: the static head, a known vision family.
|
||||
expect(codexSignInReadsImages(B.key)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips a refused slug for the vision answer too", () => {
|
||||
rememberRejectedCodexSlug(A, "gpt-5.6-terra");
|
||||
expect(resolveCodexVisionSlug(A.key)).toBe("gpt-6-sol");
|
||||
});
|
||||
|
||||
it("routes each chain entry to its own sign-in", () => {
|
||||
setCodexCatalogState(
|
||||
{ key: codexCredentialKey.user("u1"), accountId: "acct-1" },
|
||||
{
|
||||
kind: "offered",
|
||||
models: [{ ...offered("gpt-6-sol")[0], inputModalities: ["text"] }],
|
||||
at: Date.now(),
|
||||
},
|
||||
);
|
||||
expect(chainEntryReadsImages("codex", "u1", null)).toBe(false);
|
||||
expect(chainEntryReadsImages("codex", "u2", null)).toBe(true);
|
||||
expect(chainEntryReadsImages("admin-codex", "u1", null)).toBe(true);
|
||||
expect(chainEntryReadsImages("openai", "u1", "gpt-4")).toBe(false);
|
||||
expect(chainEntryReadsImages("anthropic", "u1", "claude-sonnet-4-5")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,18 @@ import {
|
||||
setCachedCodexSlug,
|
||||
CODEX_SLUG_CACHE_TTL_MS,
|
||||
} from "../codex-slug-cache";
|
||||
import {
|
||||
DEFAULT_SLUG_FALLBACK_CHAIN,
|
||||
buildCodexSlugChain,
|
||||
} from "../codex-slug-chain";
|
||||
|
||||
/** The sign-in every client in this file runs on. */
|
||||
const KEY = "user:test";
|
||||
const CREDENTIAL = { key: KEY, accountId: "acct" };
|
||||
|
||||
/** A model list that cannot be read: the client walks its static order. */
|
||||
const NO_MODEL_LIST = async () =>
|
||||
({ models: null, from: "live", failure: "network" }) as const;
|
||||
|
||||
/**
|
||||
* Slug-drift defence.
|
||||
@@ -110,6 +122,8 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -123,7 +137,7 @@ describe("Codex slug fallback chain", () => {
|
||||
"gpt-6-sol",
|
||||
]);
|
||||
expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-6-sol");
|
||||
expect(getCachedCodexSlug()).toBe("gpt-6-sol");
|
||||
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-6-sol");
|
||||
} finally {
|
||||
if (origModel === undefined) delete process.env.CODEX_MODEL;
|
||||
else process.env.CODEX_MODEL = origModel;
|
||||
@@ -138,6 +152,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -154,7 +169,7 @@ describe("Codex slug fallback chain", () => {
|
||||
expect(diagnostics?.cacheState).toBe("miss");
|
||||
expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex");
|
||||
|
||||
expect(getCachedCodexSlug()).toBe("gpt-5.3-codex");
|
||||
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("walks past first slug on 'not supported when using Codex with a ChatGPT account' 400", async () => {
|
||||
@@ -165,6 +180,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -183,7 +199,7 @@ describe("Codex slug fallback chain", () => {
|
||||
expect(diagnostics?.workingSlug).toBe("gpt-5.3-codex");
|
||||
|
||||
// The working slug — not the rejected one — is now cached.
|
||||
expect(getCachedCodexSlug()).toBe("gpt-5.3-codex");
|
||||
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("walks past first slug on 400 with model_not_found body", async () => {
|
||||
@@ -199,6 +215,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -218,6 +235,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -235,6 +253,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -255,6 +274,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -276,6 +296,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "expired",
|
||||
accountId: "acct",
|
||||
onTokenRefresh: vi
|
||||
@@ -304,6 +325,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -332,11 +354,12 @@ describe("Codex slug fallback chain", () => {
|
||||
});
|
||||
|
||||
it("cache hit — second call goes straight to the cached slug", async () => {
|
||||
setCachedCodexSlug("gpt-4o");
|
||||
setCachedCodexSlug(CREDENTIAL, "gpt-4o");
|
||||
const mockFetch = vi.fn().mockResolvedValue(sseSuccessResponse());
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -350,8 +373,8 @@ describe("Codex slug fallback chain", () => {
|
||||
});
|
||||
|
||||
it("cache invalidated when cached slug starts rejecting", async () => {
|
||||
setCachedCodexSlug("gpt-5-codex");
|
||||
expect(getCachedCodexSlug()).toBe("gpt-5-codex");
|
||||
setCachedCodexSlug(CREDENTIAL, "gpt-5-codex");
|
||||
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5-codex");
|
||||
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
@@ -360,6 +383,7 @@ describe("Codex slug fallback chain", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "test",
|
||||
accountId: "acct",
|
||||
onTokenRefresh,
|
||||
@@ -369,7 +393,7 @@ describe("Codex slug fallback chain", () => {
|
||||
|
||||
// After walking to "gpt-5.3-codex" (the next entry after the
|
||||
// cached one), the cache is updated to the new working slug.
|
||||
expect(getCachedCodexSlug()).toBe("gpt-5.3-codex");
|
||||
expect(getCachedCodexSlug(CREDENTIAL)).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("isSlugRejection helper recognises canonical rejection bodies", () => {
|
||||
@@ -401,7 +425,7 @@ describe("Codex slug fallback chain", () => {
|
||||
// served the gpt-5.6 and gpt-6 lines. Terra, verified live, leads; its
|
||||
// upstream successor and the rest of the gpt-6 line follow before terra's
|
||||
// own siblings, which a line rotation would retire together with it.
|
||||
expect([...__test.DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([
|
||||
expect([...DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([
|
||||
"gpt-5.6-terra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6.1-sol",
|
||||
@@ -419,7 +443,7 @@ describe("Codex slug fallback chain", () => {
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2",
|
||||
]) {
|
||||
expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN).not.toContain(retired);
|
||||
expect(DEFAULT_SLUG_FALLBACK_CHAIN).not.toContain(retired);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -430,8 +454,8 @@ describe("Codex slug fallback chain", () => {
|
||||
// "off" (the Codex client sends `effort: "none"` for it) that reads
|
||||
// images. Nothing is filtered out first: a new line joins the default
|
||||
// only together with its family support.
|
||||
expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN.length).toBeGreaterThan(0);
|
||||
for (const slug of __test.DEFAULT_SLUG_FALLBACK_CHAIN) {
|
||||
expect(DEFAULT_SLUG_FALLBACK_CHAIN.length).toBeGreaterThan(0);
|
||||
for (const slug of DEFAULT_SLUG_FALLBACK_CHAIN) {
|
||||
const family = openAIModelFamily(slug);
|
||||
expect(family, slug).not.toBeNull();
|
||||
expect(family?.reasoning, slug).not.toBeNull();
|
||||
@@ -445,13 +469,13 @@ describe("Codex slug fallback chain", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("loadFallbackChain falls back to the default ladder with no env override", () => {
|
||||
it("without a model list or an env override, the walk is the default ladder", () => {
|
||||
const origModel = process.env.CODEX_MODEL;
|
||||
const origChain = process.env.CODEX_MODEL_FALLBACK_CHAIN;
|
||||
delete process.env.CODEX_MODEL;
|
||||
delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
|
||||
try {
|
||||
expect(__test.loadFallbackChain()).toEqual([
|
||||
expect(buildCodexSlugChain({ offered: null }).chain).toEqual([
|
||||
"gpt-5.6-terra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6.1-sol",
|
||||
@@ -469,11 +493,11 @@ describe("Codex slug fallback chain", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("loadFallbackChain folds CODEX_MODEL into position 0", () => {
|
||||
it("the static walk folds CODEX_MODEL into position 0", () => {
|
||||
const orig = process.env.CODEX_MODEL;
|
||||
process.env.CODEX_MODEL = "custom-pinned-slug";
|
||||
try {
|
||||
const chain = __test.loadFallbackChain();
|
||||
const { chain } = buildCodexSlugChain({ offered: null });
|
||||
expect(chain[0]).toBe("custom-pinned-slug");
|
||||
// Defaults still present after position 0.
|
||||
expect(chain).toContain("gpt-5.5");
|
||||
|
||||
@@ -45,6 +45,7 @@ function sseResponse(): Response {
|
||||
|
||||
function makeClient() {
|
||||
return new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "acct",
|
||||
onTokenRefresh: vi
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -533,7 +533,7 @@ describe("local reasoning refusals the client must not learn from", () => {
|
||||
expect(body(mock, 1).reasoning_effort).toBe("low");
|
||||
});
|
||||
|
||||
it("sends the field again an hour after a refusal", async () => {
|
||||
it("holds a refusal for a day, then sends the field again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const refusal = readFileSync(
|
||||
@@ -550,9 +550,13 @@ describe("local reasoning refusals the client must not learn from", () => {
|
||||
const wanted = params({ reasoning: { effort: "low", summaries: true } });
|
||||
await client().generateCompletion(wanted);
|
||||
expect(body(mock, 1)).not.toHaveProperty("reasoning_effort");
|
||||
// #1279: the hour cost one refused request per expiry.
|
||||
vi.advanceTimersByTime(60 * 60 * 1000);
|
||||
await client().generateCompletion(wanted);
|
||||
expect(body(mock, 2).reasoning_effort).toBe("low");
|
||||
expect(body(mock, 2)).not.toHaveProperty("reasoning_effort");
|
||||
vi.advanceTimersByTime(23 * 60 * 60 * 1000);
|
||||
await client().generateCompletion(wanted);
|
||||
expect(body(mock, 3).reasoning_effort).toBe("low");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ describe("OpenAI reasoning refusals the client must not learn from", () => {
|
||||
expect(body(mock, 1).reasoning_effort).toBe("low");
|
||||
});
|
||||
|
||||
it("sends reasoning again an hour after a refusal", async () => {
|
||||
it("holds a refusal for a day, then sends reasoning again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const mock = vi
|
||||
@@ -365,9 +365,13 @@ describe("OpenAI reasoning refusals the client must not learn from", () => {
|
||||
const wanted = params({ reasoning: { effort: "low", summaries: false } });
|
||||
await c.generateCompletion(wanted);
|
||||
expect(body(mock, 1)).not.toHaveProperty("reasoning_effort");
|
||||
// #1279: the hour cost one refused request per expiry.
|
||||
vi.advanceTimersByTime(60 * 60 * 1000);
|
||||
await c.generateCompletion(wanted);
|
||||
expect(body(mock, 2).reasoning_effort).toBe("low");
|
||||
expect(body(mock, 2)).not.toHaveProperty("reasoning_effort");
|
||||
vi.advanceTimersByTime(23 * 60 * 60 * 1000);
|
||||
await c.generateCompletion(wanted);
|
||||
expect(body(mock, 3).reasoning_effort).toBe("low");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
type CompletionResult,
|
||||
} from "../types";
|
||||
|
||||
/** A model list that cannot be read: the client walks its static order. */
|
||||
const NO_MODEL_LIST = async () =>
|
||||
({ models: null, from: "live", failure: "network" }) as const;
|
||||
|
||||
// safeFetch's requirePublicHost path runs through undici's own `fetch`
|
||||
// (version-locked with its dispatcher). Delegate it to the global `fetch`
|
||||
// stub these tests install so the existing interception still applies.
|
||||
@@ -215,6 +219,8 @@ describe("AIProvider contract", () => {
|
||||
);
|
||||
|
||||
const provider: AIProvider = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
readModels: NO_MODEL_LIST,
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
|
||||
@@ -81,6 +81,7 @@ const CLIENTS: Record<string, () => AIProvider> = {
|
||||
new AnthropicClient({ apiKey: "k", model: "claude-sonnet-4-6" }),
|
||||
codex: () =>
|
||||
new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi.fn(),
|
||||
|
||||
@@ -308,6 +308,7 @@ describe("the forced final round on each provider wire", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", upstream);
|
||||
const client = new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi.fn(),
|
||||
|
||||
+60
-198
@@ -11,25 +11,21 @@ import type {
|
||||
CompletionParams,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
import {
|
||||
annotateReasoningDowngrade,
|
||||
isReasoningParameterRejection,
|
||||
learnedReasoningDialect,
|
||||
rememberReasoningDialect,
|
||||
} from "./reasoning/dialect-cache";
|
||||
import type { ReasoningLevel } from "./reasoning/levels";
|
||||
import { levelOfWireEffort, reasoningTitleOf } from "./reasoning/support";
|
||||
import {
|
||||
getCachedCodexSlug,
|
||||
invalidateCachedCodexSlug,
|
||||
setCachedCodexSlug,
|
||||
} from "./codex-slug-cache";
|
||||
CODEX_ENDPOINT,
|
||||
asksEncryptedReasoning,
|
||||
learnFromReasoningRejection,
|
||||
planCodexReasoning,
|
||||
type CodexReasoningWire,
|
||||
} from "./codex-reasoning-wire";
|
||||
import {
|
||||
DEFAULT_SLUG_FALLBACK_CHAIN,
|
||||
loadFallbackChain,
|
||||
} from "./codex-slug-chain";
|
||||
|
||||
export { resolveCodexVisionSlug } from "./codex-slug-chain";
|
||||
getCachedCodexSlug,
|
||||
rememberRejectedCodexSlug,
|
||||
setCachedCodexSlug,
|
||||
type CodexCredential,
|
||||
} from "./codex-slug-cache";
|
||||
import { loadCodexAttemptChain } from "./codex-slug-chain";
|
||||
|
||||
/**
|
||||
* Codex backend client — talks to `chatgpt.com/backend-api/codex/responses`
|
||||
@@ -47,15 +43,12 @@ export { resolveCodexVisionSlug } from "./codex-slug-chain";
|
||||
*
|
||||
* Response is always SSE (server rejects `stream: false` with 400).
|
||||
*
|
||||
* v1.4.15 (Phase C1) adds a slug-drift defence — see
|
||||
* `docs/codex-protocol-spec.md` §7b. The client walks an ordered
|
||||
* fallback chain on every fresh request series (short-circuiting on
|
||||
* a cached working slug) so a single upstream allow-list rotation no
|
||||
* longer bricks the integration.
|
||||
* Slug-drift defence (`docs/codex-protocol-spec.md` §7b): the client tries
|
||||
* its sign-in's working slug first, then walks an order built from the
|
||||
* models the account is offered (`codex-slug-chain.ts`), so an upstream
|
||||
* retirement does not take the integration down.
|
||||
*/
|
||||
|
||||
const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
/**
|
||||
* Triggers per spec §7b — body / status that mean "this slug is no
|
||||
* longer accepted, walk to the next one in the chain".
|
||||
@@ -207,142 +200,6 @@ function codexStateItems(
|
||||
);
|
||||
}
|
||||
|
||||
// ── v1.41 reasoning wire ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The Responses-API efforts in ascending order. The Codex backend names the
|
||||
* values a slug accepts in its 400 body ("Supported values are: 'none',
|
||||
* 'low', …"), which is how a slug's ladder is learned.
|
||||
*/
|
||||
const CODEX_EFFORT_ORDER = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
] as const;
|
||||
|
||||
/** What one request sends: `null` is today's `reasoning: null`. */
|
||||
type CodexReasoningWire = { effort: string; summary: boolean } | null;
|
||||
|
||||
/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */
|
||||
interface CodexReasoningDialect {
|
||||
/** The slug refused `reasoning` altogether. */
|
||||
disabled?: true;
|
||||
/** The slug refused `reasoning.summary`. */
|
||||
noSummary?: true;
|
||||
/** The efforts the slug named as supported when it refused one. */
|
||||
efforts?: string[];
|
||||
}
|
||||
|
||||
function dialectKey(slug: string) {
|
||||
return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug };
|
||||
}
|
||||
|
||||
function readCodexDialect(slug: string): CodexReasoningDialect {
|
||||
const raw = learnedReasoningDialect(dialectKey(slug));
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as CodexReasoningDialect;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function wantedEffort(level: ReasoningLevel): string {
|
||||
// "Off" is a real off on the current slugs: a 2026-10-06 probe answered
|
||||
// `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that
|
||||
// refuses it learns its lowest supported value instead.
|
||||
return level === "off" ? "none" : level;
|
||||
}
|
||||
|
||||
/** The supported effort closest to `wanted` from below, else the lowest. */
|
||||
function nearestEffort(wanted: string, supported: string[]): string | null {
|
||||
const rank = (e: string) =>
|
||||
(CODEX_EFFORT_ORDER as readonly string[]).indexOf(e);
|
||||
const known = supported
|
||||
.filter((e) => rank(e) >= 0)
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
if (known.length === 0) return null;
|
||||
const below = known.filter((e) => rank(e) <= rank(wanted));
|
||||
return below.length > 0 ? below[below.length - 1] : known[0];
|
||||
}
|
||||
|
||||
function planCodexReasoning(
|
||||
reasoning: CompletionParams["reasoning"],
|
||||
slug: string,
|
||||
): CodexReasoningWire {
|
||||
if (!reasoning) return null;
|
||||
const dialect = readCodexDialect(slug);
|
||||
if (dialect.disabled) return null;
|
||||
let effort: string | null = wantedEffort(reasoning.effort);
|
||||
if (dialect.efforts && !dialect.efforts.includes(effort)) {
|
||||
effort = nearestEffort(effort, dialect.efforts);
|
||||
}
|
||||
if (!effort) return null;
|
||||
return {
|
||||
effort,
|
||||
summary: reasoning.summaries && effort !== "none" && !dialect.noSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request asks for the encrypted reasoning items, the only
|
||||
* reasoning state a `store: false` request can hand to the next round.
|
||||
*/
|
||||
function asksEncryptedReasoning(wire: CodexReasoningWire): boolean {
|
||||
return wire !== null && wire.effort !== "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 400 that refuses a reasoning parameter and learn what the slug
|
||||
* accepts instead. Returns the lesser wire to retry with, or `undefined`
|
||||
* when the body is not about reasoning (the caller's error path takes it).
|
||||
*/
|
||||
function learnFromReasoningRejection(
|
||||
slug: string,
|
||||
wire: Exclude<CodexReasoningWire, null>,
|
||||
status: number,
|
||||
body: string,
|
||||
): CodexReasoningWire | undefined {
|
||||
// Only a 400 that refuses the reasoning parameter counts; a 400 about the
|
||||
// prompt or a tool, and a replay error about a reasoning item handed back
|
||||
// from an earlier round, must not teach the slug anything.
|
||||
if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) {
|
||||
return undefined;
|
||||
}
|
||||
const dialect = readCodexDialect(slug);
|
||||
let next: CodexReasoningWire;
|
||||
if (wire.summary && /reasoning\.summary|summary/i.test(body)) {
|
||||
dialect.noSummary = true;
|
||||
next = { effort: wire.effort, summary: false };
|
||||
} else {
|
||||
const listed = /supported values are:([^"\n]*)/i.exec(body);
|
||||
const efforts = listed
|
||||
? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1])
|
||||
: [];
|
||||
const nearest =
|
||||
efforts.length > 0 && !efforts.includes(wire.effort)
|
||||
? nearestEffort(wire.effort, efforts)
|
||||
: null;
|
||||
if (nearest) {
|
||||
dialect.efforts = efforts;
|
||||
next = { effort: nearest, summary: wire.summary && nearest !== "none" };
|
||||
} else {
|
||||
dialect.disabled = true;
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect));
|
||||
annotateReasoningDowngrade(
|
||||
"codex",
|
||||
wire.effort,
|
||||
next ? next.effort : "default",
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map tool defs into the Responses `function` tool shape.
|
||||
*
|
||||
@@ -369,10 +226,13 @@ interface CodexClientConfig {
|
||||
accountId: string;
|
||||
onTokenRefresh: () => Promise<{ accessToken: string; accountId: string }>;
|
||||
/**
|
||||
* Override the slug fallback chain (test-only). When unset, the
|
||||
* chain is loaded from env + DEFAULT_SLUG_FALLBACK_CHAIN.
|
||||
* The stored credential (`userCodexCredentialKey` or
|
||||
* `CENTRAL_CODEX_CREDENTIAL_KEY`) its working slug, refused slugs and model
|
||||
* list are kept under; and two test seams (see `loadCodexAttemptChain`).
|
||||
*/
|
||||
credentialKey: string;
|
||||
slugChain?: string[];
|
||||
readModels?: Parameters<typeof loadCodexAttemptChain>[0]["readModels"];
|
||||
}
|
||||
|
||||
/** Diagnostic shape returned to the route layer for Wide-Event logging. */
|
||||
@@ -407,53 +267,59 @@ export class CodexClient implements AIProvider {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
}>;
|
||||
private readonly slugChain: string[];
|
||||
private readonly selection: Pick<
|
||||
CodexClientConfig,
|
||||
"credentialKey" | "slugChain" | "readModels"
|
||||
>;
|
||||
private lastDiagnostics: CodexAttemptDiagnostics | null = null;
|
||||
|
||||
constructor(config: CodexClientConfig) {
|
||||
this.accessToken = config.accessToken;
|
||||
this.accountId = config.accountId;
|
||||
this.onTokenRefresh = config.onTokenRefresh;
|
||||
this.slugChain = config.slugChain ?? loadFallbackChain();
|
||||
if (this.slugChain.length === 0) {
|
||||
// Hardcoded floor — even with a misconfigured env var we always
|
||||
// try at least one slug.
|
||||
this.slugChain = [...DEFAULT_SLUG_FALLBACK_CHAIN];
|
||||
}
|
||||
this.selection = config;
|
||||
}
|
||||
|
||||
private get credential(): CodexCredential {
|
||||
return { key: this.selection.credentialKey, accountId: this.accountId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics for the most recent generateCompletion() call, used by
|
||||
* the route layer for Wide-Event annotations. Captures the order of
|
||||
* attempted slugs, whether the cache was hit, and which slug
|
||||
* eventually worked (or null on all-failed).
|
||||
* The last generateCompletion(): slugs attempted in order, whether the
|
||||
* working slug was cached, and which slug answered (null when none did).
|
||||
*/
|
||||
getLastDiagnostics(): CodexAttemptDiagnostics | null {
|
||||
return this.lastDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the slug-attempt order: cached slug first (if any and not
|
||||
* expired), then the rest of the fallback chain in order, deduped
|
||||
* against the cached slug.
|
||||
*/
|
||||
private buildAttemptOrder(): { order: string[]; cacheState: "hit" | "miss" } {
|
||||
const cached = getCachedCodexSlug();
|
||||
if (cached === null) {
|
||||
return { order: [...this.slugChain], cacheState: "miss" };
|
||||
}
|
||||
const order = [cached, ...this.slugChain.filter((s) => s !== cached)];
|
||||
return { order, cacheState: "hit" };
|
||||
}
|
||||
|
||||
async generateCompletion(
|
||||
params: CompletionParams,
|
||||
): Promise<CompletionResult> {
|
||||
const { order, cacheState } = this.buildAttemptOrder();
|
||||
// The working slug alone first: the account's list is read only when
|
||||
// there is none, or when it stops working.
|
||||
const cached = getCachedCodexSlug(this.credential);
|
||||
const cacheState = cached === null ? "miss" : "hit";
|
||||
const loadChain = () =>
|
||||
loadCodexAttemptChain({
|
||||
...this.selection,
|
||||
credential: this.credential,
|
||||
accessToken: this.accessToken,
|
||||
});
|
||||
const queue = cached ? [cached] : await loadChain();
|
||||
let chainLoaded = cached === null;
|
||||
const attempted: string[] = [];
|
||||
let lastSlugRejectionError: Error | null = null;
|
||||
|
||||
for (const slug of order) {
|
||||
for (;;) {
|
||||
if (queue.length === 0) {
|
||||
if (chainLoaded) break;
|
||||
chainLoaded = true;
|
||||
queue.push(
|
||||
...(await loadChain()).filter((s) => !attempted.includes(s)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const slug = queue.shift() as string;
|
||||
attempted.push(slug);
|
||||
|
||||
let wire = planCodexReasoning(params.reasoning, slug);
|
||||
@@ -472,7 +338,7 @@ export class CodexClient implements AIProvider {
|
||||
cacheState,
|
||||
workingSlug: slug,
|
||||
};
|
||||
setCachedCodexSlug(slug);
|
||||
setCachedCodexSlug(this.credential, slug);
|
||||
return this.parseResponse(retryAfterRefresh, slug, params, wire);
|
||||
}
|
||||
// Even after refresh — surface the auth failure verbatim, do
|
||||
@@ -512,7 +378,7 @@ export class CodexClient implements AIProvider {
|
||||
cacheState,
|
||||
workingSlug: slug,
|
||||
};
|
||||
setCachedCodexSlug(slug);
|
||||
setCachedCodexSlug(this.credential, slug);
|
||||
return this.parseResponse(attempt, slug, params, wire);
|
||||
}
|
||||
|
||||
@@ -521,10 +387,9 @@ export class CodexClient implements AIProvider {
|
||||
const bodyExcerpt = redactBody(rawBody);
|
||||
|
||||
if (isSlugRejection(attempt.status, bodyExcerpt)) {
|
||||
// Slug rejected — drop the cache (in case it pointed here),
|
||||
// record the failure for diagnostics, and walk to the next
|
||||
// chain slot.
|
||||
invalidateCachedCodexSlug();
|
||||
// Refused for this sign-in: out of the working slot and to the back
|
||||
// of its line, even while its list still offers it; walk on.
|
||||
rememberRejectedCodexSlug(this.credential, slug);
|
||||
const slugErr = new Error(
|
||||
`Codex slug "${slug}" rejected (${attempt.status})`,
|
||||
);
|
||||
@@ -1036,10 +901,7 @@ function redactBody(rawBody: string): string {
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
|
||||
}
|
||||
|
||||
// Test-only re-exports — keeps the spec-driven defaults visible to unit
|
||||
// tests without exporting a settable mutable.
|
||||
// Test-only: the rejection matcher.
|
||||
export const __test = {
|
||||
DEFAULT_SLUG_FALLBACK_CHAIN,
|
||||
isSlugRejection,
|
||||
loadFallbackChain,
|
||||
};
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* The model list a Codex sign-in is offered, read the way the official Codex
|
||||
* CLI reads it.
|
||||
*
|
||||
* A fixed preference list goes stale at every retirement: on 2026-10-14 every
|
||||
* model the shipped list named was retired for ChatGPT sign-in and Codex
|
||||
* answered 503 until a release changed the list. The backend says which
|
||||
* models an account may use, on the same sign-in as the responses call:
|
||||
*
|
||||
* GET https://chatgpt.com/backend-api/codex/models?client_version=<x.y.z>
|
||||
* Authorization: Bearer <access token>
|
||||
* ChatGPT-Account-ID: <account id>
|
||||
*
|
||||
* Wire shape, from openai/codex `codex-rs/protocol/src/openai_models.rs`
|
||||
* (`ModelsResponse`, `ModelInfo`, `ModelVisibility`, `InputModality`), read
|
||||
* by `codex-rs/codex-api/src/endpoint/models.rs` (`ModelsClient`, which
|
||||
* appends `client_version`) under the 5 s bound and per-account cache
|
||||
* identity of `codex-rs/model-provider/src/models_endpoint.rs` and
|
||||
* `models_identity.rs`:
|
||||
*
|
||||
* { "models": [ { "slug": "gpt-6-luna", "visibility": "list",
|
||||
* "supported_in_api": true, "priority": 4,
|
||||
* "input_modalities": ["text", "image"],
|
||||
* "model_specialty": null, ... } ] }
|
||||
*
|
||||
* Only the fields read below matter here; everything else in an entry (the
|
||||
* instruction templates make up most of a ~500 KB body) is ignored.
|
||||
*
|
||||
* The list is a hint, never a gate. A read that fails, times out, runs past
|
||||
* the size bound, does not parse or comes back empty leaves the client on
|
||||
* its built-in order, exactly as before the list existed. A failed read is
|
||||
* retried after `CODEX_CATALOG_RETRY_MS`, not on every request, and one
|
||||
* signal per sign-in reports the stretch.
|
||||
*/
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
import { readBodyCapped } from "@/lib/http/read-capped";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import {
|
||||
codexCredentialKey,
|
||||
getCodexCatalogState,
|
||||
setCodexCatalogState,
|
||||
type CodexCatalogState,
|
||||
type CodexCredential,
|
||||
} from "./codex-slug-cache";
|
||||
|
||||
const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
|
||||
|
||||
/**
|
||||
* The `client_version` sent with the read. The backend leaves out models
|
||||
* whose `minimal_client_version` is above it (every gpt-6 entry in the
|
||||
* upstream `models-manager/models.json` names 0.153.0 or later), so a version
|
||||
* far behind the current Codex CLI hides the models an account actually has.
|
||||
* This is the Codex CLI release current when it was last checked
|
||||
* (rust-v0.165.0, 2026-10-27); move it when a new line needs a newer client.
|
||||
*/
|
||||
export const CODEX_MODELS_CLIENT_VERSION = "0.165.0";
|
||||
|
||||
/** Same bound as the Codex CLI's model refresh. */
|
||||
const MODELS_TIMEOUT_MS = 5_000;
|
||||
/** The upstream bundle is ~540 KB for twelve models. */
|
||||
const MODELS_MAX_BYTES = 4 * 1024 * 1024;
|
||||
/** More entries than any account has been offered; the rest are ignored. */
|
||||
const MAX_MODELS = 200;
|
||||
/** What a slug may look like before it goes into a request body or a log. */
|
||||
const SLUG_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
|
||||
|
||||
const ORIGINATOR = "healthlog";
|
||||
const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)";
|
||||
|
||||
/** One model the account is offered, reduced to what the client uses. */
|
||||
export interface CodexOfferedModel {
|
||||
slug: string;
|
||||
/** `visibility: "list"`: shown in the Codex CLI's picker. */
|
||||
listed: boolean;
|
||||
/** The list's own order (lower first); null when the entry has none. */
|
||||
priority: number | null;
|
||||
/** `input_modalities`, or null when the entry does not say. */
|
||||
inputModalities: readonly string[] | null;
|
||||
/** `model_specialty` (e.g. a security-focused line), null for general models. */
|
||||
specialty: string | null;
|
||||
}
|
||||
|
||||
/** Why a read of the list gave nothing usable. A closed set: it is log meta. */
|
||||
export type CodexCatalogFailure =
|
||||
| "timeout"
|
||||
| "network"
|
||||
| "auth"
|
||||
| "http_error"
|
||||
| "too_large"
|
||||
| "unparsable"
|
||||
| "empty";
|
||||
|
||||
/** What one request learned about the list. */
|
||||
export interface CodexCatalogRead {
|
||||
/** The offered models, or null when the list could not be used. */
|
||||
models: readonly CodexOfferedModel[] | null;
|
||||
/** Whether this request read the backend or used an earlier read. */
|
||||
from: "live" | "cache";
|
||||
failure: CodexCatalogFailure | null;
|
||||
}
|
||||
|
||||
/** The access a read needs: the same sign-in as the responses call. */
|
||||
export interface CodexCatalogAuth {
|
||||
accessToken: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `/models` body. Returns null when the body is not the documented
|
||||
* envelope; entries without a usable slug are skipped, not fatal. An empty
|
||||
* array is returned as such and the caller treats it as unusable.
|
||||
*/
|
||||
export function parseCodexModelList(body: unknown): CodexOfferedModel[] | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const list = (body as { models?: unknown }).models;
|
||||
if (!Array.isArray(list)) return null;
|
||||
const seen = new Set<string>();
|
||||
const out: CodexOfferedModel[] = [];
|
||||
for (const raw of list.slice(0, MAX_MODELS)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
const slug = typeof entry.slug === "string" ? entry.slug.trim() : "";
|
||||
if (!SLUG_SHAPE.test(slug) || seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
const modalities = Array.isArray(entry.input_modalities)
|
||||
? entry.input_modalities.filter((m): m is string => typeof m === "string")
|
||||
: null;
|
||||
out.push({
|
||||
slug,
|
||||
listed: entry.visibility === "list",
|
||||
priority:
|
||||
typeof entry.priority === "number" && Number.isFinite(entry.priority)
|
||||
? entry.priority
|
||||
: null,
|
||||
inputModalities: modalities,
|
||||
specialty:
|
||||
typeof entry.model_specialty === "string" && entry.model_specialty
|
||||
? entry.model_specialty
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One read of the backend, never cached and never thrown out of. */
|
||||
export async function fetchCodexModelList(
|
||||
auth: CodexCatalogAuth,
|
||||
): Promise<
|
||||
| { ok: true; models: CodexOfferedModel[] }
|
||||
| { ok: false; reason: CodexCatalogFailure; status: number | null }
|
||||
> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await safeFetch(
|
||||
`${CODEX_MODELS_ENDPOINT}?client_version=${CODEX_MODELS_CLIENT_VERSION}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${auth.accessToken}`,
|
||||
"ChatGPT-Account-ID": auth.accountId,
|
||||
originator: ORIGINATOR,
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: MODELS_TIMEOUT_MS },
|
||||
);
|
||||
} catch (error) {
|
||||
const timedOut =
|
||||
(error instanceof SafeFetchError && error.kind === "timeout") ||
|
||||
(error instanceof Error && error.name === "TimeoutError");
|
||||
return {
|
||||
ok: false,
|
||||
reason: timedOut ? "timeout" : "network",
|
||||
status: null,
|
||||
};
|
||||
}
|
||||
if (!res.ok) {
|
||||
void res.body?.cancel().catch(() => {});
|
||||
return {
|
||||
ok: false,
|
||||
reason: res.status === 401 || res.status === 403 ? "auth" : "http_error",
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
const read = await readBodyCapped(res, MODELS_MAX_BYTES);
|
||||
if (!read.ok) return { ok: false, reason: "too_large", status: res.status };
|
||||
text = read.text;
|
||||
} catch (error) {
|
||||
// The timeout signal also covers the body; a stall mid-body lands here.
|
||||
const timedOut = error instanceof Error && error.name === "TimeoutError";
|
||||
return {
|
||||
ok: false,
|
||||
reason: timedOut ? "timeout" : "network",
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, reason: "unparsable", status: res.status };
|
||||
}
|
||||
const models = parseCodexModelList(json);
|
||||
if (models === null) {
|
||||
return { ok: false, reason: "unparsable", status: res.status };
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return { ok: false, reason: "empty", status: res.status };
|
||||
}
|
||||
return { ok: true, models };
|
||||
}
|
||||
|
||||
/** Reads in flight, one per sign-in, so a burst of requests reads once. */
|
||||
const inflight = new Map<string, Promise<CodexCatalogRead>>();
|
||||
|
||||
/**
|
||||
* The account's model list for this sign-in: a trusted earlier read when
|
||||
* there is one, else one bounded read of the backend shared by every request
|
||||
* that asks meanwhile.
|
||||
*
|
||||
* An expired token (401) is not remembered: the responses call refreshes it
|
||||
* on its own 401, and the next request reads the list with the new one.
|
||||
* Every other failure is remembered for `CODEX_CATALOG_RETRY_MS`.
|
||||
*/
|
||||
export function readCodexModelCatalog(
|
||||
credential: CodexCredential,
|
||||
auth: CodexCatalogAuth,
|
||||
): Promise<CodexCatalogRead> {
|
||||
const state = getCodexCatalogState(credential);
|
||||
if (state?.kind === "offered") {
|
||||
return Promise.resolve({
|
||||
models: state.models,
|
||||
from: "cache",
|
||||
failure: null,
|
||||
});
|
||||
}
|
||||
if (state?.kind === "unreadable") {
|
||||
return Promise.resolve({
|
||||
models: null,
|
||||
from: "cache",
|
||||
failure: state.reason,
|
||||
});
|
||||
}
|
||||
const pending = inflight.get(credential.key);
|
||||
if (pending) return pending;
|
||||
const read = fetchCodexModelList(auth)
|
||||
.then((result): CodexCatalogRead => {
|
||||
if (result.ok) {
|
||||
setCodexCatalogState(credential, {
|
||||
kind: "offered",
|
||||
models: result.models,
|
||||
at: Date.now(),
|
||||
});
|
||||
return { models: result.models, from: "live", failure: null };
|
||||
}
|
||||
if (result.status !== 401) {
|
||||
const first = setCodexCatalogState(credential, {
|
||||
kind: "unreadable",
|
||||
reason: result.reason,
|
||||
at: Date.now(),
|
||||
});
|
||||
if (first) {
|
||||
emitSignal({
|
||||
action: "ai.codex.models_unreadable",
|
||||
level: "warn",
|
||||
meta: {
|
||||
connection:
|
||||
credential.key === codexCredentialKey.central
|
||||
? "central"
|
||||
: "personal",
|
||||
reason: result.reason,
|
||||
status: result.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return { models: null, from: "live", failure: result.reason };
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(credential.key) === read) {
|
||||
inflight.delete(credential.key);
|
||||
}
|
||||
});
|
||||
inflight.set(credential.key, read);
|
||||
return read;
|
||||
}
|
||||
|
||||
/** The offered models from an earlier read, without touching the network. */
|
||||
export function cachedCodexModels(
|
||||
credential: CodexCredential,
|
||||
now: number = Date.now(),
|
||||
): readonly CodexOfferedModel[] | null {
|
||||
return offeredModels(getCodexCatalogState(credential, now));
|
||||
}
|
||||
|
||||
/** The models a list read offered, or null when it offered none. */
|
||||
export function offeredModels(
|
||||
state: CodexCatalogState | null,
|
||||
): readonly CodexOfferedModel[] | null {
|
||||
return state?.kind === "offered" ? state.models : null;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The reasoning half of the Codex wire: what `reasoning` field a request
|
||||
* sends for a requested level, and what a slug that refuses it is asked
|
||||
* instead. A slug's refusals are learned per slug in the shared dialect cache
|
||||
* (`reasoning/dialect-cache.ts`), so the next request does not pay the
|
||||
* refused round-trip again.
|
||||
*/
|
||||
import {
|
||||
annotateReasoningDowngrade,
|
||||
isReasoningParameterRejection,
|
||||
learnedReasoningDialect,
|
||||
rememberReasoningDialect,
|
||||
} from "./reasoning/dialect-cache";
|
||||
import type { ReasoningLevel } from "./reasoning/levels";
|
||||
import type { CompletionParams } from "./types";
|
||||
|
||||
/** The responses endpoint, and the key its learned dialects are kept under. */
|
||||
export const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
/**
|
||||
* The Responses-API efforts in ascending order. The Codex backend names the
|
||||
* values a slug accepts in its 400 body ("Supported values are: 'none',
|
||||
* 'low', …"), which is how a slug's ladder is learned.
|
||||
*/
|
||||
const CODEX_EFFORT_ORDER = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
] as const;
|
||||
|
||||
/** What one request sends: `null` is today's `reasoning: null`. */
|
||||
export type CodexReasoningWire = { effort: string; summary: boolean } | null;
|
||||
|
||||
/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */
|
||||
interface CodexReasoningDialect {
|
||||
/** The slug refused `reasoning` altogether. */
|
||||
disabled?: true;
|
||||
/** The slug refused `reasoning.summary`. */
|
||||
noSummary?: true;
|
||||
/** The efforts the slug named as supported when it refused one. */
|
||||
efforts?: string[];
|
||||
}
|
||||
|
||||
function dialectKey(slug: string) {
|
||||
return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug };
|
||||
}
|
||||
|
||||
function readCodexDialect(slug: string): CodexReasoningDialect {
|
||||
const raw = learnedReasoningDialect(dialectKey(slug));
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as CodexReasoningDialect;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function wantedEffort(level: ReasoningLevel): string {
|
||||
// "Off" is a real off on the current slugs: a 2026-10-06 probe answered
|
||||
// `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that
|
||||
// refuses it learns its lowest supported value instead.
|
||||
return level === "off" ? "none" : level;
|
||||
}
|
||||
|
||||
/** The supported effort closest to `wanted` from below, else the lowest. */
|
||||
function nearestEffort(wanted: string, supported: string[]): string | null {
|
||||
const rank = (e: string) =>
|
||||
(CODEX_EFFORT_ORDER as readonly string[]).indexOf(e);
|
||||
const known = supported
|
||||
.filter((e) => rank(e) >= 0)
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
if (known.length === 0) return null;
|
||||
const below = known.filter((e) => rank(e) <= rank(wanted));
|
||||
return below.length > 0 ? below[below.length - 1] : known[0];
|
||||
}
|
||||
|
||||
export function planCodexReasoning(
|
||||
reasoning: CompletionParams["reasoning"],
|
||||
slug: string,
|
||||
): CodexReasoningWire {
|
||||
if (!reasoning) return null;
|
||||
const dialect = readCodexDialect(slug);
|
||||
if (dialect.disabled) return null;
|
||||
let effort: string | null = wantedEffort(reasoning.effort);
|
||||
if (dialect.efforts && !dialect.efforts.includes(effort)) {
|
||||
effort = nearestEffort(effort, dialect.efforts);
|
||||
}
|
||||
if (!effort) return null;
|
||||
return {
|
||||
effort,
|
||||
summary: reasoning.summaries && effort !== "none" && !dialect.noSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request asks for the encrypted reasoning items, the only
|
||||
* reasoning state a `store: false` request can hand to the next round.
|
||||
*/
|
||||
export function asksEncryptedReasoning(wire: CodexReasoningWire): boolean {
|
||||
return wire !== null && wire.effort !== "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 400 that refuses a reasoning parameter and learn what the slug
|
||||
* accepts instead. Returns the lesser wire to retry with, or `undefined`
|
||||
* when the body is not about reasoning (the caller's error path takes it).
|
||||
*/
|
||||
export function learnFromReasoningRejection(
|
||||
slug: string,
|
||||
wire: Exclude<CodexReasoningWire, null>,
|
||||
status: number,
|
||||
body: string,
|
||||
): CodexReasoningWire | undefined {
|
||||
// Only a 400 that refuses the reasoning parameter counts; a 400 about the
|
||||
// prompt or a tool, and a replay error about a reasoning item handed back
|
||||
// from an earlier round, must not teach the slug anything.
|
||||
if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) {
|
||||
return undefined;
|
||||
}
|
||||
const dialect = readCodexDialect(slug);
|
||||
let next: CodexReasoningWire;
|
||||
if (wire.summary && /reasoning\.summary|summary/i.test(body)) {
|
||||
dialect.noSummary = true;
|
||||
next = { effort: wire.effort, summary: false };
|
||||
} else {
|
||||
const listed = /supported values are:([^"\n]*)/i.exec(body);
|
||||
const efforts = listed
|
||||
? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1])
|
||||
: [];
|
||||
const nearest =
|
||||
efforts.length > 0 && !efforts.includes(wire.effort)
|
||||
? nearestEffort(wire.effort, efforts)
|
||||
: null;
|
||||
if (nearest) {
|
||||
dialect.efforts = efforts;
|
||||
next = { effort: nearest, summary: wire.summary && nearest !== "none" };
|
||||
} else {
|
||||
dialect.disabled = true;
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect));
|
||||
annotateReasoningDowngrade(
|
||||
"codex",
|
||||
wire.effort,
|
||||
next ? next.effort : "default",
|
||||
);
|
||||
return next;
|
||||
}
|
||||
+263
-35
@@ -1,62 +1,290 @@
|
||||
/**
|
||||
* Process-local positive cache for working Codex model slugs.
|
||||
* Process-local memory per Codex sign-in: the model that last worked, the
|
||||
* models the backend refused, and the model list the account was offered.
|
||||
*
|
||||
* (v1.4.15) — slug-drift defence per
|
||||
* `docs/codex-protocol-spec.md` §7b. The ChatGPT-Codex backend rotates
|
||||
* its allow-list silently; pinning a single `CODEX_MODEL` slug bricks
|
||||
* the integration the moment upstream flips it. Instead of pinning,
|
||||
* `CodexClient` walks an ordered fallback chain on every fresh
|
||||
* request series and caches the first slug that returns 200 +
|
||||
* `response.completed` for `CACHE_TTL_MS` so subsequent requests skip
|
||||
* the chain walk.
|
||||
* The ChatGPT-Codex backend offers different models to different plans and
|
||||
* retires them without notice (docs/codex-protocol-spec.md §7b). One slot for
|
||||
* the whole process meant two sign-ins on different plans overwrote each
|
||||
* other's working model, and the vision gate read whichever had answered
|
||||
* last. Every entry here belongs to one stored credential instead:
|
||||
*
|
||||
* Lives in its own module so:
|
||||
* - Tests can `clearCodexSlugCache()` between cases.
|
||||
* - The shared Map state stays out of `CodexClient` itself (so
|
||||
* instance creation in `resolveProvider` doesn't reset the cache).
|
||||
* - Future telemetry (cache hit-rate emission) hooks here cleanly.
|
||||
* - `user:<id>` for a person's own Codex connection;
|
||||
* - `central` for the operator-shared connection (`admin-codex`).
|
||||
*
|
||||
* An entry also remembers a digest of the ChatGPT account id it was learned
|
||||
* under, and a client reads it only under that account: a credential
|
||||
* reconnected to a different account (a different plan) reads as empty from
|
||||
* its first request, before anything is written under the new account, so no
|
||||
* slug, refusal or list learned on the old account carries over. That holds
|
||||
* in every process, including one that never saw the reconnect.
|
||||
*
|
||||
* The vision gate is the one reader without an account id (the provider
|
||||
* presence probe decrypts nothing), so it reads the entry as this process last
|
||||
* bound it ({@link codexGateMemory}). The routes that connect or disconnect a
|
||||
* credential rebind or drop the entry ({@link startCodexSignIn},
|
||||
* {@link forgetCodexSignIn}), so in the process that handled the change the
|
||||
* gate answers from the new sign-in at once; elsewhere it catches up with that
|
||||
* process's first request on the new sign-in, and a wrong guess in between is
|
||||
* what the runtime probe and the OCR fallback already cover. No token, and no
|
||||
* raw account id, is ever a key or a value here.
|
||||
*
|
||||
* Bounded both ways: every part of an entry expires on its own TTL, and the
|
||||
* map holds at most `CODEX_CREDENTIAL_CACHE_MAX` sign-ins, the least recently
|
||||
* used dropped first. A dropped entry costs one extra model-list read and at
|
||||
* most one refused request; it never costs an answer.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
CodexCatalogFailure,
|
||||
CodexOfferedModel,
|
||||
} from "./codex-model-catalog";
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour per spec §7b
|
||||
/** How long a working model stays first in line (spec §7b). */
|
||||
export const CODEX_SLUG_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
/** How long a model the backend refused stays at the back of the line. */
|
||||
export const CODEX_REJECTED_SLUG_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** How long the account's model list is trusted before it is read again. */
|
||||
export const CODEX_CATALOG_TTL_MS = 60 * 60 * 1000;
|
||||
/** How long a list that could not be read waits before the next attempt. */
|
||||
export const CODEX_CATALOG_RETRY_MS = 10 * 60 * 1000;
|
||||
/** Most sign-ins held at once. */
|
||||
export const CODEX_CREDENTIAL_CACHE_MAX = 1_000;
|
||||
|
||||
interface CachedEntry {
|
||||
slug: string;
|
||||
cachedAtMs: number;
|
||||
/** The keys a sign-in's memory is kept under. */
|
||||
export const codexCredentialKey = {
|
||||
/** A person's own Codex connection (the `codex` chain entry). */
|
||||
user: (userId: string): string => `user:${userId}`,
|
||||
/** The operator-shared central connection (the `admin-codex` entry). */
|
||||
central: "central",
|
||||
} as const;
|
||||
|
||||
/** The sign-in a client runs on: its stored credential and its account. */
|
||||
export interface CodexCredential {
|
||||
key: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/** What the last read of the account's model list left behind. */
|
||||
export type CodexCatalogState =
|
||||
| { kind: "offered"; models: readonly CodexOfferedModel[]; at: number }
|
||||
| { kind: "unreadable"; reason: CodexCatalogFailure; at: number };
|
||||
|
||||
interface CredentialEntry {
|
||||
accountDigest: string;
|
||||
working: { slug: string; at: number } | null;
|
||||
rejected: Map<string, number>;
|
||||
catalog: CodexCatalogState | null;
|
||||
/** Whether the current unreadable stretch has been reported. */
|
||||
unreadableReported: boolean;
|
||||
}
|
||||
|
||||
const entries = new Map<string, CredentialEntry>();
|
||||
|
||||
function accountDigest(accountId: string): string {
|
||||
return createHash("sha256")
|
||||
.update(`codex-account:${accountId}`)
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
/** Read an entry whatever account it was learned under; LRU-touches a hit. */
|
||||
function peek(key: string): CredentialEntry | undefined {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return undefined;
|
||||
entries.delete(key);
|
||||
entries.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key — currently a single global slot ("codex"). Kept in a Map
|
||||
* so future per-account / per-model-family scoping is a one-line
|
||||
* change.
|
||||
* The entry for a running client's sign-in, created on first use and started
|
||||
* afresh when the credential now belongs to a different account.
|
||||
*/
|
||||
const CACHE_KEY = "codex";
|
||||
function bind(credential: CodexCredential): CredentialEntry {
|
||||
const digest = accountDigest(credential.accountId);
|
||||
const existing = peek(credential.key);
|
||||
if (existing && existing.accountDigest === digest) return existing;
|
||||
const fresh: CredentialEntry = {
|
||||
accountDigest: digest,
|
||||
working: null,
|
||||
rejected: new Map(),
|
||||
catalog: null,
|
||||
unreadableReported: false,
|
||||
};
|
||||
entries.set(credential.key, fresh);
|
||||
while (entries.size > CODEX_CREDENTIAL_CACHE_MAX) {
|
||||
const oldest = entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
entries.delete(oldest);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedEntry>();
|
||||
/**
|
||||
* The entry learned under this sign-in's account, or none: an entry left by
|
||||
* another account on the same credential reads as empty.
|
||||
*/
|
||||
function read(credential: CodexCredential): CredentialEntry | undefined {
|
||||
const entry = peek(credential.key);
|
||||
if (!entry) return undefined;
|
||||
return entry.accountDigest === accountDigest(credential.accountId)
|
||||
? entry
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getCachedCodexSlug(now: number = Date.now()): string | null {
|
||||
const entry = cache.get(CACHE_KEY);
|
||||
if (!entry) return null;
|
||||
if (now - entry.cachedAtMs > CACHE_TTL_MS) {
|
||||
cache.delete(CACHE_KEY);
|
||||
function liveWorking(
|
||||
entry: CredentialEntry | undefined,
|
||||
now: number,
|
||||
): string | null {
|
||||
if (!entry?.working) return null;
|
||||
if (now - entry.working.at > CODEX_SLUG_CACHE_TTL_MS) {
|
||||
entry.working = null;
|
||||
return null;
|
||||
}
|
||||
return entry.slug;
|
||||
return entry.working.slug;
|
||||
}
|
||||
|
||||
function liveRejected(
|
||||
entry: CredentialEntry | undefined,
|
||||
now: number,
|
||||
): ReadonlySet<string> {
|
||||
if (!entry) return new Set();
|
||||
for (const [slug, at] of entry.rejected) {
|
||||
if (now - at > CODEX_REJECTED_SLUG_TTL_MS) entry.rejected.delete(slug);
|
||||
}
|
||||
return new Set(entry.rejected.keys());
|
||||
}
|
||||
|
||||
function liveCatalog(
|
||||
entry: CredentialEntry | undefined,
|
||||
now: number,
|
||||
): CodexCatalogState | null {
|
||||
const state = entry?.catalog;
|
||||
if (!entry || !state) return null;
|
||||
const ttl =
|
||||
state.kind === "offered" ? CODEX_CATALOG_TTL_MS : CODEX_CATALOG_RETRY_MS;
|
||||
if (now - state.at > ttl) {
|
||||
entry.catalog = null;
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** The model that last worked for this sign-in, while it is fresh. */
|
||||
export function getCachedCodexSlug(
|
||||
credential: CodexCredential,
|
||||
now: number = Date.now(),
|
||||
): string | null {
|
||||
return liveWorking(read(credential), now);
|
||||
}
|
||||
|
||||
export function setCachedCodexSlug(
|
||||
credential: CodexCredential,
|
||||
slug: string,
|
||||
now: number = Date.now(),
|
||||
): void {
|
||||
cache.set(CACHE_KEY, { slug, cachedAtMs: now });
|
||||
const entry = bind(credential);
|
||||
entry.working = { slug, at: now };
|
||||
entry.rejected.delete(slug);
|
||||
}
|
||||
|
||||
export function invalidateCachedCodexSlug(): void {
|
||||
cache.delete(CACHE_KEY);
|
||||
/**
|
||||
* Record that the backend refused `slug` for this sign-in (a retired or
|
||||
* not-offered model). It drops out of the working slot and goes to the back
|
||||
* of the line until `CODEX_REJECTED_SLUG_TTL_MS` passes, even while the
|
||||
* account's list still offers it.
|
||||
*/
|
||||
export function rememberRejectedCodexSlug(
|
||||
credential: CodexCredential,
|
||||
slug: string,
|
||||
now: number = Date.now(),
|
||||
): void {
|
||||
const entry = bind(credential);
|
||||
if (entry.working?.slug === slug) entry.working = null;
|
||||
entry.rejected.set(slug, now);
|
||||
}
|
||||
|
||||
/** Test-only: reset the cache between cases. */
|
||||
/** The models this sign-in was refused recently. */
|
||||
export function rejectedCodexSlugs(
|
||||
credential: CodexCredential,
|
||||
now: number = Date.now(),
|
||||
): ReadonlySet<string> {
|
||||
return liveRejected(read(credential), now);
|
||||
}
|
||||
|
||||
/** The last read of the account's model list, while it is still trusted. */
|
||||
export function getCodexCatalogState(
|
||||
credential: CodexCredential,
|
||||
now: number = Date.now(),
|
||||
): CodexCatalogState | null {
|
||||
return liveCatalog(read(credential), now);
|
||||
}
|
||||
|
||||
/** What the vision gate reasons from for one stored credential. */
|
||||
export interface CodexGateMemory {
|
||||
working: string | null;
|
||||
rejected: ReadonlySet<string>;
|
||||
catalog: CodexCatalogState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential's memory as this process last bound it, for the vision gate
|
||||
* alone, which holds no account id (see the module header). Every other
|
||||
* reader goes through the account-checked reads above.
|
||||
*/
|
||||
export function codexGateMemory(
|
||||
key: string,
|
||||
now: number = Date.now(),
|
||||
): CodexGateMemory {
|
||||
const entry = peek(key);
|
||||
return {
|
||||
working: liveWorking(entry, now),
|
||||
rejected: liveRejected(entry, now),
|
||||
catalog: liveCatalog(entry, now),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential has just been connected to `accountId`: a reconnect to another
|
||||
* account starts the entry afresh, so the vision gate in this process stops
|
||||
* reading what the old account learned; a reconnect to the same account keeps
|
||||
* it.
|
||||
*/
|
||||
export function startCodexSignIn(credential: CodexCredential): void {
|
||||
bind(credential);
|
||||
}
|
||||
|
||||
/** A credential has been disconnected: forget what it learned. */
|
||||
export function forgetCodexSignIn(key: string): void {
|
||||
entries.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a read of the account's model list. Returns true when this is the
|
||||
* first unreadable result since the list last answered, which is the one
|
||||
* the caller reports; a later failure in the same stretch returns false.
|
||||
*/
|
||||
export function setCodexCatalogState(
|
||||
credential: CodexCredential,
|
||||
state: CodexCatalogState,
|
||||
): boolean {
|
||||
const entry = bind(credential);
|
||||
entry.catalog = state;
|
||||
if (state.kind === "offered") {
|
||||
entry.unreadableReported = false;
|
||||
return false;
|
||||
}
|
||||
if (entry.unreadableReported) return false;
|
||||
entry.unreadableReported = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test-only: forget every sign-in. */
|
||||
export function clearCodexSlugCache(): void {
|
||||
cache.clear();
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
export const CODEX_SLUG_CACHE_TTL_MS = CACHE_TTL_MS;
|
||||
/** Test-only: how many sign-ins are held. */
|
||||
export function codexCredentialCacheSize(): number {
|
||||
return entries.size;
|
||||
}
|
||||
+231
-33
@@ -1,17 +1,34 @@
|
||||
/**
|
||||
* The Codex slug ladder: which ChatGPT-auth model slugs the client tries, in
|
||||
* order, and the slug the vision gate reasons about before a request runs.
|
||||
* The walk itself (which statuses move to the next rung, the positive cache)
|
||||
* lives in `codex-client.ts` and `codex-slug-cache.ts`; docs/codex-protocol-spec.md
|
||||
* §7b describes the whole defence.
|
||||
* The walk itself (which statuses move to the next rung) lives in
|
||||
* `codex-client.ts`; the per-sign-in memory in `codex-slug-cache.ts`; the
|
||||
* account's own model list in `codex-model-catalog.ts`.
|
||||
* docs/codex-protocol-spec.md §7b describes the whole defence.
|
||||
*/
|
||||
import { getCachedCodexSlug } from "./codex-slug-cache";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
offeredModels,
|
||||
readCodexModelCatalog,
|
||||
type CodexCatalogRead,
|
||||
type CodexOfferedModel,
|
||||
} from "./codex-model-catalog";
|
||||
import {
|
||||
codexCredentialKey,
|
||||
codexGateMemory,
|
||||
rejectedCodexSlugs,
|
||||
type CodexCredential,
|
||||
} from "./codex-slug-cache";
|
||||
import {
|
||||
supportsVisionForConfig,
|
||||
type VisionProviderType,
|
||||
} from "./vision-capability";
|
||||
|
||||
/**
|
||||
* Default fallback chain. Override via `CODEX_MODEL_FALLBACK_CHAIN`
|
||||
* (comma-separated); `CODEX_MODEL` is folded into position 0 when set,
|
||||
* duplicates dropped. The backend rotates its ChatGPT-auth slugs without
|
||||
* notice: by 2026-10-24 it rejected everything from `gpt-5.5` down.
|
||||
* Our preference order. With the account's model list in hand it is filtered
|
||||
* to what the account is offered; without it, it is the walk. Override via
|
||||
* `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated); `CODEX_MODEL` is folded
|
||||
* into position 0 when set, duplicates dropped.
|
||||
*
|
||||
* `gpt-5.6-terra` stays first because it is the slug verified against the
|
||||
* backend. The upstream catalog (`codex-rs/models-manager/models.json`)
|
||||
@@ -33,37 +50,218 @@ export const DEFAULT_SLUG_FALLBACK_CHAIN = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The slug the codex vision gate reasons about *before* a request runs: the
|
||||
* cached working slug if one is live, else the first chain entry. Lets the
|
||||
* Lab-OCR capability resolver decide whether codex can read images without
|
||||
* standing up a client or making a network call. Best-effort — the actual
|
||||
* routed slug may differ once the chain walk runs, which the runtime probe +
|
||||
* graceful fallback (text/local OCR) covers.
|
||||
* The frontier tier (`gpt-6-astra` today, and any later `-astra`), skipped
|
||||
* even when offered, for the reason on `DEFAULT_SLUG_FALLBACK_CHAIN`. An
|
||||
* operator who wants it names it in `CODEX_MODEL`.
|
||||
*/
|
||||
export function resolveCodexVisionSlug(): string | null {
|
||||
const cached = getCachedCodexSlug();
|
||||
if (cached) return cached;
|
||||
return loadFallbackChain()[0] ?? null;
|
||||
const SKIPPED_TIER = /(?:^|-)astra(?:$|-)/;
|
||||
|
||||
/** Where the order a client walks came from. A closed set: it is log meta. */
|
||||
export type CodexChainSource = "operator" | "live" | "static";
|
||||
|
||||
function dedupe(slugs: readonly string[]): string[] {
|
||||
return [...new Set(slugs)];
|
||||
}
|
||||
|
||||
/** The chain a client walks: the env override or the default, `CODEX_MODEL` first. */
|
||||
export function loadFallbackChain(): string[] {
|
||||
/** The `CODEX_MODEL` pin and the `CODEX_MODEL_FALLBACK_CHAIN` replacement. */
|
||||
function operatorChoice(): { pinned: string | null; chain: string[] | null } {
|
||||
const envChain = process.env.CODEX_MODEL_FALLBACK_CHAIN?.trim();
|
||||
const fromEnv = envChain
|
||||
const chain = envChain
|
||||
? envChain
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [...DEFAULT_SLUG_FALLBACK_CHAIN];
|
||||
const pinned = process.env.CODEX_MODEL?.trim();
|
||||
const merged = pinned ? [pinned, ...fromEnv] : fromEnv;
|
||||
// Stable de-duplication.
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const slug of merged) {
|
||||
if (seen.has(slug)) continue;
|
||||
seen.add(slug);
|
||||
result.push(slug);
|
||||
}
|
||||
return result;
|
||||
: null;
|
||||
return {
|
||||
pinned: process.env.CODEX_MODEL?.trim() || null,
|
||||
chain: chain && chain.length > 0 ? chain : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the operator replaced the chain, so the account's list is not read. */
|
||||
function operatorReplacedCodexChain(): boolean {
|
||||
return operatorChoice().chain !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order a client walks for one sign-in.
|
||||
*
|
||||
* 1. The operator's choice, unchanged in meaning: `CODEX_MODEL` goes first;
|
||||
* `CODEX_MODEL_FALLBACK_CHAIN` replaces everything after it, and the
|
||||
* account's list is then not consulted at all.
|
||||
* 2. Our preference order, kept to what the account is offered.
|
||||
* 3. Every other offered model, in the list's own order (`priority`, as the
|
||||
* Codex CLI sorts it), leaving out what the list hides from its picker
|
||||
* (`visibility` other than `list`: review and specialist models), the
|
||||
* specialist lines (`model_specialty`), and the skipped frontier tier.
|
||||
* `supported_in_api` is not a filter: it describes API-key access, and
|
||||
* the Codex CLI ignores it for a ChatGPT sign-in (`filter_by_auth`).
|
||||
* 4. The rest of our preference order, last. A list that offers a model the
|
||||
* backend then refuses must not be able to take Codex down, so the walk
|
||||
* never holds fewer models than it did before the list was read; these
|
||||
* only cost a refused request when everything ahead of them failed.
|
||||
*
|
||||
* Without a usable list (none read, a failed read, an empty one) the order is
|
||||
* today's static walk. Models this sign-in was refused recently move to the
|
||||
* very end, in their order, whatever step placed them.
|
||||
*/
|
||||
export function buildCodexSlugChain(input: {
|
||||
offered: readonly CodexOfferedModel[] | null;
|
||||
rejected?: ReadonlySet<string>;
|
||||
}): { chain: string[]; source: CodexChainSource } {
|
||||
const { pinned, chain: operatorChain } = operatorChoice();
|
||||
let ordered: string[];
|
||||
let source: CodexChainSource;
|
||||
if (operatorChain) {
|
||||
ordered = operatorChain;
|
||||
source = "operator";
|
||||
} else if (!input.offered || input.offered.length === 0) {
|
||||
ordered = [...DEFAULT_SLUG_FALLBACK_CHAIN];
|
||||
source = "static";
|
||||
} else {
|
||||
const usable = input.offered.filter(
|
||||
(m) => m.listed && m.specialty === null && !SKIPPED_TIER.test(m.slug),
|
||||
);
|
||||
const usableSlugs = new Set(usable.map((m) => m.slug));
|
||||
const preferred = DEFAULT_SLUG_FALLBACK_CHAIN.filter((s) =>
|
||||
usableSlugs.has(s),
|
||||
);
|
||||
const others = usable
|
||||
.map((m, index) => ({ m, index }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.m.priority ?? Number.POSITIVE_INFINITY) -
|
||||
(b.m.priority ?? Number.POSITIVE_INFINITY) || a.index - b.index,
|
||||
)
|
||||
.map(({ m }) => m.slug);
|
||||
ordered = [...preferred, ...others, ...DEFAULT_SLUG_FALLBACK_CHAIN];
|
||||
source = "live";
|
||||
}
|
||||
const walk = dedupe(pinned ? [pinned, ...ordered] : ordered);
|
||||
const rejected = input.rejected ?? new Set<string>();
|
||||
return {
|
||||
chain: [
|
||||
...walk.filter((s) => !rejected.has(s)),
|
||||
...walk.filter((s) => rejected.has(s)),
|
||||
],
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/** How a client reads its account's model list. */
|
||||
export type CodexModelReader = typeof readCodexModelCatalog;
|
||||
|
||||
/**
|
||||
* The walk a client runs after its working slug, for one sign-in, with the
|
||||
* outcome on the request's wide event (pinned shape, closed values):
|
||||
*
|
||||
* - `codex.models.chain`: where the order came from (`CodexChainSource`);
|
||||
* - `codex.models.read`: `live`, `cache`, or `skipped` when the operator
|
||||
* replaced the chain and the list was not asked;
|
||||
* - `codex.models.failure`: why the list was unusable (`CodexCatalogFailure`)
|
||||
* or null;
|
||||
* - `codex.models.offered`: how many models the list offered (0 without one).
|
||||
*
|
||||
* `slugChain` and `readModels` are test seams: `slugChain` replaces the walk
|
||||
* the way `CODEX_MODEL_FALLBACK_CHAIN` does (the list is not read, refused
|
||||
* slugs still go last); `readModels` stands in for the backend read.
|
||||
*/
|
||||
export async function loadCodexAttemptChain(input: {
|
||||
credential: CodexCredential;
|
||||
accessToken: string;
|
||||
slugChain?: readonly string[];
|
||||
readModels?: CodexModelReader;
|
||||
}): Promise<string[]> {
|
||||
const rejected = rejectedCodexSlugs(input.credential);
|
||||
if (input.slugChain && input.slugChain.length > 0) {
|
||||
return [
|
||||
...input.slugChain.filter((s) => !rejected.has(s)),
|
||||
...input.slugChain.filter((s) => rejected.has(s)),
|
||||
];
|
||||
}
|
||||
let read: CodexCatalogRead | null = null;
|
||||
if (!operatorReplacedCodexChain()) {
|
||||
const reader = input.readModels ?? readCodexModelCatalog;
|
||||
// The reader never rejects by contract; a bug in it must still leave
|
||||
// the static walk rather than fail the request.
|
||||
read = await reader(input.credential, {
|
||||
accessToken: input.accessToken,
|
||||
accountId: input.credential.accountId,
|
||||
}).catch((): CodexCatalogRead => ({
|
||||
models: null,
|
||||
from: "live",
|
||||
failure: "network",
|
||||
}));
|
||||
}
|
||||
const { chain, source } = buildCodexSlugChain({
|
||||
offered: read?.models ?? null,
|
||||
rejected,
|
||||
});
|
||||
annotate({
|
||||
meta: {
|
||||
"codex.models.chain": source,
|
||||
"codex.models.read": read?.from ?? "skipped",
|
||||
"codex.models.failure": read?.failure ?? null,
|
||||
"codex.models.offered": read?.models?.length ?? 0,
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slug the codex vision gate reasons about *before* a request runs, for
|
||||
* one sign-in: its working slug if one is live, else the head of the order
|
||||
* built from its last model list (or the static walk). Lets the vision
|
||||
* resolvers decide whether codex can read images without standing up a
|
||||
* client or making a network call. Best-effort: the routed slug may differ
|
||||
* once the walk runs, which the runtime probe and the text / local OCR
|
||||
* fallback cover.
|
||||
*/
|
||||
export function resolveCodexVisionSlug(credentialKey: string): string | null {
|
||||
const memory = codexGateMemory(credentialKey);
|
||||
if (memory.working) return memory.working;
|
||||
const { chain } = buildCodexSlugChain({
|
||||
offered: operatorReplacedCodexChain()
|
||||
? null
|
||||
: offeredModels(memory.catalog),
|
||||
rejected: memory.rejected,
|
||||
});
|
||||
return chain[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this sign-in's Codex model reads images. When the account's list
|
||||
* names the model's `input_modalities`, that answer wins; otherwise the model
|
||||
* family decides (`supportsVisionForConfig`), as it did before the list.
|
||||
*/
|
||||
export function codexSignInReadsImages(credentialKey: string): boolean {
|
||||
const slug = resolveCodexVisionSlug(credentialKey);
|
||||
if (!slug) return false;
|
||||
const offered = offeredModels(codexGateMemory(credentialKey).catalog)?.find(
|
||||
(m) => m.slug === slug,
|
||||
);
|
||||
if (offered?.inputModalities) {
|
||||
return offered.inputModalities.includes("image");
|
||||
}
|
||||
return supportsVisionForConfig("codex", slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider chain entry can read images, for the vision resolvers.
|
||||
* A Codex entry asks its own sign-in (`codex` the person's connection,
|
||||
* `admin-codex` the operator-shared one); every other entry is decided by
|
||||
* `model`'s family, as before.
|
||||
*/
|
||||
export function chainEntryReadsImages(
|
||||
providerType: string,
|
||||
userId: string,
|
||||
model: string | null,
|
||||
): boolean {
|
||||
if (providerType === "codex") {
|
||||
return codexSignInReadsImages(codexCredentialKey.user(userId));
|
||||
}
|
||||
if (providerType === "admin-codex") {
|
||||
return codexSignInReadsImages(codexCredentialKey.central);
|
||||
}
|
||||
return supportsVisionForConfig(providerType as VisionProviderType, model);
|
||||
}
|
||||
+12
-16
@@ -5,7 +5,9 @@ import { bindReasoningEffort } from "./reasoning-effort";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { openStoredKey } from "./stored-key";
|
||||
import { isAnthropicBaseUrl } from "./anthropic-host";
|
||||
import { CodexClient, resolveCodexVisionSlug } from "./codex-client";
|
||||
import { CodexClient } from "./codex-client";
|
||||
import { codexCredentialKey } from "./codex-slug-cache";
|
||||
import { chainEntryReadsImages } from "./codex-slug-chain";
|
||||
import { OpenAIClient } from "./openai-client";
|
||||
import { AnthropicClient } from "./anthropic-client";
|
||||
import { LocalOpenAICompatibleClient } from "./local-client";
|
||||
@@ -45,10 +47,6 @@ import type {
|
||||
ProviderEntryPresence,
|
||||
ProviderPresence,
|
||||
} from "@/lib/ai/capabilities/resolve";
|
||||
import {
|
||||
supportsVisionForConfig,
|
||||
type VisionProviderType,
|
||||
} from "./vision-capability";
|
||||
|
||||
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -602,6 +600,7 @@ async function resolveCodexProvider(
|
||||
return new CodexClient({
|
||||
accessToken: active.accessToken,
|
||||
accountId: active.accountId,
|
||||
credentialKey: codexCredentialKey.user(userId),
|
||||
onTokenRefresh: async () => {
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -690,6 +689,7 @@ async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
|
||||
return new CodexClient({
|
||||
accessToken: active.accessToken,
|
||||
accountId: active.accountId,
|
||||
credentialKey: codexCredentialKey.central,
|
||||
onTokenRefresh: async () => {
|
||||
const current = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
@@ -1151,22 +1151,18 @@ const NO_PRESENCE: ProviderPresence = Object.freeze({
|
||||
|
||||
/**
|
||||
* Whether one chain entry's model can read an image. Mirrors the model each
|
||||
* entry runs on (`src/lib/labs/ocr-capability.ts`): the Codex paths use the
|
||||
* working Codex slug, the operator's slot uses the operator's model, every
|
||||
* other entry the person's own.
|
||||
* entry runs on (`src/lib/labs/ocr-capability.ts`): each Codex path asks its
|
||||
* own sign-in, the operator's slot uses the operator's model, every other
|
||||
* entry the person's own.
|
||||
*/
|
||||
function entryVision(
|
||||
providerType: string,
|
||||
recordId: string,
|
||||
userModel: string | null,
|
||||
adminModel: string | null,
|
||||
): boolean {
|
||||
const model =
|
||||
providerType === "codex" || providerType === "admin-codex"
|
||||
? resolveCodexVisionSlug()
|
||||
: providerType === "admin-openai"
|
||||
? adminModel
|
||||
: userModel;
|
||||
return supportsVisionForConfig(providerType as VisionProviderType, model);
|
||||
const model = providerType === "admin-openai" ? adminModel : userModel;
|
||||
return chainEntryReadsImages(providerType, recordId, model);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1212,7 +1208,7 @@ export function probeProviderChain(
|
||||
const adminModel = settings?.adminAiModel ?? null;
|
||||
const vision = (providerType: string): ProviderEntryPresence => ({
|
||||
providerType,
|
||||
vision: entryVision(providerType, row.aiModel, adminModel),
|
||||
vision: entryVision(providerType, recordId, row.aiModel, adminModel),
|
||||
});
|
||||
|
||||
if (policy === "deny") return NO_PRESENCE;
|
||||
|
||||
@@ -9,13 +9,18 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
ANSWERS_ONLY_UNREASONED,
|
||||
annotateReasoningDowngrade,
|
||||
DIALECT_TTL_MS,
|
||||
FIELD_REFUSED_REASONS_REGARDLESS,
|
||||
FIELD_REFUSED_TTL_MS,
|
||||
REASONS_REGARDLESS,
|
||||
isReasoningParameterRejection,
|
||||
learnedReasoningDialect,
|
||||
rememberReasoningDialect,
|
||||
resetReasoningDialectCache,
|
||||
} from "../dialect-cache";
|
||||
import { learnReasoningFieldRefused, nextAnswerlessStep } from "../answerless";
|
||||
|
||||
beforeEach(() => {
|
||||
resetReasoningDialectCache();
|
||||
@@ -25,7 +30,7 @@ beforeEach(() => {
|
||||
describe("reasoning dialect cache", () => {
|
||||
it("keys on provider, endpoint and model together", () => {
|
||||
const key = { provider: "local", endpoint: "http://a/v1", model: "m1" };
|
||||
rememberReasoningDialect(key, "none");
|
||||
rememberReasoningDialect(key, "none", "refusal");
|
||||
expect(learnedReasoningDialect(key)).toBe("none");
|
||||
expect(learnedReasoningDialect({ ...key, model: "m2" })).toBeUndefined();
|
||||
expect(
|
||||
@@ -56,19 +61,91 @@ describe("reasoning dialect cache", () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const key = { provider: "openai", endpoint: "http://g/v1", model: "m" };
|
||||
rememberReasoningDialect(key, "none");
|
||||
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
|
||||
vi.advanceTimersByTime(DIALECT_TTL_MS - 1);
|
||||
expect(learnedReasoningDialect(key)).toBe("none");
|
||||
expect(learnedReasoningDialect(key)).toBe(ANSWERS_ONLY_UNREASONED);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(learnedReasoningDialect(key)).toBeUndefined();
|
||||
rememberReasoningDialect(key, "none");
|
||||
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
|
||||
vi.advanceTimersByTime(DIALECT_TTL_MS / 2);
|
||||
expect(learnedReasoningDialect(key)).toBe("none");
|
||||
expect(learnedReasoningDialect(key)).toBe(REASONS_REGARDLESS);
|
||||
expect(DIALECT_TTL_MS).toBe(60 * 60 * 1000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// #1279: at the hour, a gateway that refuses the field paid one refused
|
||||
// request per expiry, a few a day. Only a 400 refusing the field teaches
|
||||
// the day.
|
||||
it.each([
|
||||
["none", false],
|
||||
[FIELD_REFUSED_REASONS_REGARDLESS, true],
|
||||
] as const)(
|
||||
"holds a refused field a 400 taught (%s) for a day, not an hour",
|
||||
(value, reasonedUnasked) => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const key = { provider: "openai", endpoint: "http://g/v1", model: "m" };
|
||||
if (reasonedUnasked) {
|
||||
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
|
||||
}
|
||||
learnReasoningFieldRefused(key, "openai", "low");
|
||||
expect(learnedReasoningDialect(key)).toBe(value);
|
||||
vi.advanceTimersByTime(DIALECT_TTL_MS * 2);
|
||||
expect(learnedReasoningDialect(key)).toBe(value);
|
||||
vi.advanceTimersByTime(FIELD_REFUSED_TTL_MS - DIALECT_TTL_MS * 2 - 1);
|
||||
expect(learnedReasoningDialect(key)).toBe(value);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(learnedReasoningDialect(key)).toBeUndefined();
|
||||
expect(FIELD_REFUSED_TTL_MS).toBe(24 * 60 * 60 * 1000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a refused field's own day when one reply adds that the model reasons regardless", () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const key = { provider: "local", endpoint: "http://l/v1", model: "m" };
|
||||
learnReasoningFieldRefused(key, "local", "low");
|
||||
// Late in the field's day, an answerless reply that reasoned.
|
||||
const late = FIELD_REFUSED_TTL_MS - 2 * DIALECT_TTL_MS;
|
||||
vi.advanceTimersByTime(late);
|
||||
expect(nextAnswerlessStep(key, null, true)).toEqual({
|
||||
kind: "final",
|
||||
learnedRegardless: true,
|
||||
});
|
||||
expect(learnedReasoningDialect(key)).toBe(
|
||||
FIELD_REFUSED_REASONS_REGARDLESS,
|
||||
);
|
||||
// Not cut to the hour: the field refusal would come back after it.
|
||||
vi.advanceTimersByTime(DIALECT_TTL_MS + 1);
|
||||
expect(learnedReasoningDialect(key)).toBe(
|
||||
FIELD_REFUSED_REASONS_REGARDLESS,
|
||||
);
|
||||
// Not a fresh day either: one reply taught it, and the field's day
|
||||
// ends where the 400 set it.
|
||||
vi.advanceTimersByTime(DIALECT_TTL_MS - 1);
|
||||
expect(learnedReasoningDialect(key)).toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a behavioural lesson that replaces a refused field the hour", () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const key = { provider: "local", endpoint: "http://l/v1", model: "m" };
|
||||
rememberReasoningDialect(key, "none", "refusal");
|
||||
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
|
||||
vi.advanceTimersByTime(DIALECT_TTL_MS);
|
||||
expect(learnedReasoningDialect(key)).toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReasoningParameterRejection", () => {
|
||||
|
||||
@@ -71,18 +71,18 @@ export function nextAnswerlessStep(
|
||||
// The endpoint refuses the field: there is no `none` to ask with.
|
||||
if (learned === "none") {
|
||||
if (!hadReasoning) return { kind: "final", learnedRegardless: false };
|
||||
rememberReasoningDialect(key, FIELD_REFUSED_REASONS_REGARDLESS);
|
||||
rememberReasoningDialect(key, FIELD_REFUSED_REASONS_REGARDLESS, "reply");
|
||||
return { kind: "final", learnedRegardless: true };
|
||||
}
|
||||
if (sent === "none") {
|
||||
if (!hadReasoning) return { kind: "final", learnedRegardless: false };
|
||||
rememberReasoningDialect(key, REASONS_REGARDLESS);
|
||||
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
|
||||
return { kind: "final", learnedRegardless: true };
|
||||
}
|
||||
if (!hadReasoning && sent === null) {
|
||||
return { kind: "final", learnedRegardless: false };
|
||||
}
|
||||
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED);
|
||||
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
|
||||
return { kind: "ask_unreasoned" };
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ export function learnReasoningFieldRefused(
|
||||
rememberReasoningDialect(
|
||||
key,
|
||||
reasonsUnasked(key) ? FIELD_REFUSED_REASONS_REGARDLESS : "none",
|
||||
"refusal",
|
||||
);
|
||||
annotateReasoningDowngrade(provider, sent, "none");
|
||||
// One line per lesson, not per call: the operator learns that the
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* v1.41 — what an endpoint answered when it was asked to reason, learned at
|
||||
* runtime and kept for an hour (`DIALECT_TTL_MS`), so an endpoint that
|
||||
* gains the parameter, or a downgrade learned from a misread error, heals
|
||||
* without a restart.
|
||||
* runtime and kept for an hour (`DIALECT_TTL_MS`), a reasoning field a 400
|
||||
* refused for a day (`FIELD_REFUSED_TTL_MS`), so an endpoint that gains the
|
||||
* parameter, or a downgrade learned from a misread error, heals without a
|
||||
* restart.
|
||||
*
|
||||
* Every client that sends reasoning parameters to a wire it does not control
|
||||
* (a gateway, a local server, a model name the client has never seen, a Codex
|
||||
@@ -76,6 +77,19 @@ export function refusesReasoningField(learned: string | undefined): boolean {
|
||||
/** How long a learned downgrade holds before the full request is tried again. */
|
||||
export const DIALECT_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* #1279 — how long a reasoning field refused by a 400 holds
|
||||
* (`refusesReasoningField`). A gateway that refuses the field does so by its
|
||||
* configuration (LiteLLM without `drop_params`), which changes when its
|
||||
* operator changes it, not between two calls, and only a 400 naming the
|
||||
* field with refusal wording can teach it. At the hour every expiry cost one
|
||||
* refused request, a few a day on a gateway that runs the briefings
|
||||
* unattended. A day bounds that to one per day, and a gateway that gains the
|
||||
* field still gets it the next day. A lesson read from a single reply keeps
|
||||
* the hour.
|
||||
*/
|
||||
export const FIELD_REFUSED_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Pinned on `globalThis`: a module imported from more than one bundler layer
|
||||
* is instantiated once per layer (see `caches` in `server-cache.ts`), so the
|
||||
@@ -108,12 +122,46 @@ export function learnedReasoningDialect(
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/** Record what `key` accepts after it refused a richer request. */
|
||||
/**
|
||||
* What taught a lesson: a 400 that refused a parameter the client sent, or a
|
||||
* single reply (one that came back without an answer).
|
||||
*/
|
||||
export type ReasoningLessonSource = "refusal" | "reply";
|
||||
|
||||
/**
|
||||
* Record what `key` accepts, by default after a 400 refused a richer request;
|
||||
* a lesson read from a reply says so (`"reply"`). A reasoning field a 400
|
||||
* refused holds for `FIELD_REFUSED_TTL_MS`, every other lesson for
|
||||
* `DIALECT_TTL_MS`.
|
||||
*
|
||||
* `FIELD_REFUSED_REASONS_REGARDLESS` carries two facts, and a reply only ever
|
||||
* adds the second ("reasons regardless") to a field refusal a 400 taught
|
||||
* earlier. That upgrade keeps the refusal's remaining lifetime: a fresh day
|
||||
* would let one reply extend what the 400 taught, and the hour would bring
|
||||
* the refused request back after it. The "regardless" half rides along and
|
||||
* is learned again from the next answerless reply if it lapses.
|
||||
*/
|
||||
export function rememberReasoningDialect(
|
||||
key: ReasoningDialectKey,
|
||||
value: string,
|
||||
source: ReasoningLessonSource = "refusal",
|
||||
): void {
|
||||
learned.set(cacheKey(key), { value, expiresAt: Date.now() + DIALECT_TTL_MS });
|
||||
const k = cacheKey(key);
|
||||
const now = Date.now();
|
||||
let expiresAt = now + DIALECT_TTL_MS;
|
||||
if (refusesReasoningField(value)) {
|
||||
const before = learned.get(k);
|
||||
if (source === "refusal") {
|
||||
expiresAt = now + FIELD_REFUSED_TTL_MS;
|
||||
} else if (
|
||||
before &&
|
||||
before.expiresAt > now &&
|
||||
refusesReasoningField(before.value)
|
||||
) {
|
||||
expiresAt = before.expiresAt;
|
||||
}
|
||||
}
|
||||
learned.set(k, { value, expiresAt });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -425,6 +425,49 @@ export type TimelineResponse = z.infer<typeof timelineResponseSchema>;
|
||||
export type TimelineLane = TimelineResponse["lanes"][number];
|
||||
export type TimelineItem = TimelineLane["items"][number];
|
||||
export type TimelineSeries = TimelineResponse["series"][number];
|
||||
export type TimelineLabSeries = TimelineResponse["labSeries"][number];
|
||||
export type TimelineLabPoint = TimelineLabSeries["points"][number];
|
||||
export type TimelineLabMarker = TimelineResponse["availableLabs"][number];
|
||||
|
||||
/**
|
||||
* A lab marker's line is named in `values` as `lab:<biomarkerId>` (#1299),
|
||||
* beside the measurement types and `MOOD`, so one choice, one request and
|
||||
* one stored list carry every line under the lanes.
|
||||
*/
|
||||
export const TIMELINE_LAB_KEY_PREFIX = "lab:";
|
||||
|
||||
/**
|
||||
* The most lab markers one request draws. Measurement names are a closed
|
||||
* set and bound themselves; marker ids are not.
|
||||
*/
|
||||
export const TIMELINE_LAB_SERIES_MAX = 24;
|
||||
|
||||
/** The marker id a `values` entry names, or null when it names no marker. */
|
||||
export function timelineLabMarkerId(key: string): string | null {
|
||||
if (!key.startsWith(TIMELINE_LAB_KEY_PREFIX)) return null;
|
||||
const id = key.slice(TIMELINE_LAB_KEY_PREFIX.length);
|
||||
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `keys` with every lab marker past the first `TIMELINE_LAB_SERIES_MAX` left
|
||||
* out, everything else in its place. The server applies it to what it is
|
||||
* asked for and the page to what it asks, so a stored list longer than the
|
||||
* cap (another device, an older build) loads its first markers instead of
|
||||
* failing the page.
|
||||
*/
|
||||
export function capTimelineLabKeys(keys: readonly string[]): string[] {
|
||||
let labs = 0;
|
||||
return keys.filter(
|
||||
(key) =>
|
||||
timelineLabMarkerId(key) === null || ++labs <= TIMELINE_LAB_SERIES_MAX,
|
||||
);
|
||||
}
|
||||
|
||||
/** The `values` entry for a lab marker. */
|
||||
export function timelineLabKey(biomarkerId: string): string {
|
||||
return `${TIMELINE_LAB_KEY_PREFIX}${biomarkerId}`;
|
||||
}
|
||||
|
||||
/** `GET /api/timeline/readiness` → `data`. */
|
||||
export type TimelineReadinessResponse = z.infer<
|
||||
|
||||
@@ -370,7 +370,7 @@ export const timelineQuerySchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422.",
|
||||
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name or a malformed marker key answers 422. At most 24 lab markers are drawn (ids, unlike names, are not a closed set): the first 24 are kept and any further ones left out without an error. A lab marker that is not in the record (deleted, or another record's) is likewise left out of `labSeries`, so a stored choice never breaks the page.",
|
||||
),
|
||||
})
|
||||
.refine((q) => q.zoom !== "range" || (q.from && q.to), {
|
||||
@@ -466,6 +466,90 @@ const timelineSeries = z
|
||||
})
|
||||
.meta({ id: "TimelineSeries" });
|
||||
|
||||
const labRangeStatus = z
|
||||
.enum(["in-range", "below", "above", "unknown"])
|
||||
.describe(
|
||||
"The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.",
|
||||
);
|
||||
|
||||
const timelineLabPoint = z
|
||||
.object({
|
||||
id: z.string().describe("The lab result's id."),
|
||||
date: dateKeySchema.describe(
|
||||
"The record's local day of the draw, the day `GET /api/day/{date}` lists it under.",
|
||||
),
|
||||
value: z.number().describe("In the series' `unit`."),
|
||||
sourceValue: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The figure the report printed, when `value` was converted into the series' unit; it sets the precision `value` is shown with. Null for a reading stored as stated.",
|
||||
),
|
||||
rangeStatus: labRangeStatus,
|
||||
referenceLow: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The window this reading is judged against, in the series' unit: the one its report printed, else the marker's own.",
|
||||
),
|
||||
referenceHigh: z.number().nullable(),
|
||||
report: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The document's title, else its file name."),
|
||||
href: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"The document the reading was read from, when one is linked and the caller may see documents.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TimelineLabPoint" });
|
||||
|
||||
const timelineLabSeries = z
|
||||
.object({
|
||||
key: z.string().describe("`lab:<biomarkerId>`, as named in `values`."),
|
||||
biomarkerId: z.string(),
|
||||
name: z.string().describe("The marker's name, as the labs list shows it."),
|
||||
panel: z.string().nullable(),
|
||||
unit: z.string().describe("The marker's own unit; every point is in it."),
|
||||
band: z
|
||||
.object({ low: z.number().nullable(), high: z.number().nullable() })
|
||||
.nullable()
|
||||
.describe(
|
||||
"The reference range to draw behind the points: the marker's own, else the window printed with its newest reading in the window. Null when neither is known.",
|
||||
),
|
||||
points: z
|
||||
.array(timelineLabPoint)
|
||||
.describe(
|
||||
"Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A day with two readings has two points.",
|
||||
),
|
||||
excluded: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe(
|
||||
"Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` rather than drawn on a scale they are not in.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "TimelineLabSeries" });
|
||||
|
||||
const timelineLabMarker = z
|
||||
.object({
|
||||
key: z.string().describe("`lab:<biomarkerId>`, to name in `values`."),
|
||||
biomarkerId: z.string(),
|
||||
name: z.string(),
|
||||
panel: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The marker's group, as the labs list shows it."),
|
||||
unit: z.string(),
|
||||
})
|
||||
.meta({ id: "TimelineLabMarker" });
|
||||
|
||||
export const timelineResponseSchema = z
|
||||
.object({
|
||||
zoom: z.enum(TIMELINE_ZOOMS),
|
||||
@@ -496,6 +580,16 @@ export const timelineResponseSchema = z
|
||||
.describe(
|
||||
"Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`.",
|
||||
),
|
||||
labSeries: z
|
||||
.array(timelineLabSeries)
|
||||
.describe(
|
||||
"The lab markers named in `values`, in the order named, each with its single readings in the window. A marker named but not found in the record is left out.",
|
||||
),
|
||||
availableLabs: z
|
||||
.array(timelineLabMarker)
|
||||
.describe(
|
||||
"Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`.",
|
||||
),
|
||||
notable: z.array(
|
||||
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Whose word a restored AI endpoint is taken on. An admin's own copy is the
|
||||
* operator's configuration, so the operator's wider endpoint policy applies;
|
||||
* a copy uploaded into an account other than the one that wrote it is the
|
||||
* word of whoever wrote it, whatever the receiving account's role.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { restoreAccountSettings } from "@/lib/export/account-settings-restore";
|
||||
|
||||
const PRIVATE_ENDPOINT = "http://10.20.30.40:11434";
|
||||
|
||||
function adminTx() {
|
||||
const update = vi.fn(async () => ({}));
|
||||
const tx = {
|
||||
user: {
|
||||
findUniqueOrThrow: vi.fn(async () => ({
|
||||
role: "ADMIN",
|
||||
aiProvider: null,
|
||||
unitPreference: "metric",
|
||||
glucoseUnit: "mg/dL",
|
||||
})),
|
||||
update,
|
||||
},
|
||||
} as unknown as Prisma.TransactionClient;
|
||||
return { tx, update };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
accountSettings: { aiProvider: "LOCAL", aiBaseUrl: PRIVATE_ENDPOINT },
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("restoring an admin account's settings", () => {
|
||||
it("takes a private AI endpoint from the admin's own copy under the operator's policy", async () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
const { tx, update } = adminTx();
|
||||
const result = await restoreAccountSettings(tx, "admin-1", payload, {
|
||||
retargeted: false,
|
||||
});
|
||||
expect(result.refused).toEqual([]);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "admin-1" },
|
||||
data: { aiProvider: "LOCAL", aiBaseUrl: PRIVATE_ENDPOINT },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses it from a copy that was uploaded into the account from someone else's file", async () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
const { tx, update } = adminTx();
|
||||
const result = await restoreAccountSettings(tx, "admin-1", payload, {
|
||||
retargeted: true,
|
||||
});
|
||||
expect(result.refused).toEqual(["aiBaseUrl"]);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "admin-1" },
|
||||
data: { aiProvider: "LOCAL" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* `rewriteBackupOwner` must change the top-level `userId` and nothing else,
|
||||
* over any chunk boundaries: the output parses to the input with that one
|
||||
* member replaced, and is byte-identical to it everywhere outside the value.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BackupJsonError } from "../backup-json-scan";
|
||||
import {
|
||||
BackupOwnerRewriteError,
|
||||
rewriteBackupOwner,
|
||||
} from "../backup-owner-rewrite";
|
||||
|
||||
async function* chunked(text: string, sizes: number[]) {
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
let at = 0;
|
||||
let i = 0;
|
||||
while (at < bytes.length) {
|
||||
const size = sizes[i++ % sizes.length]!;
|
||||
yield bytes.subarray(at, at + size);
|
||||
at += size;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(text: string, target: string, sizes: number[] = [7]) {
|
||||
const rewrite = rewriteBackupOwner(chunked(text, sizes), target);
|
||||
const parts: Buffer[] = [];
|
||||
for await (const part of rewrite.bytes) parts.push(part);
|
||||
return {
|
||||
text: Buffer.concat(parts).toString("utf8"),
|
||||
declared: rewrite.declaredOwner(),
|
||||
};
|
||||
}
|
||||
|
||||
const NESTED = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-10-01T00:00:00.000Z",
|
||||
userId: "cerased0000000000000000",
|
||||
measurements: [
|
||||
{ id: "m1", userId: "cerased0000000000000000", note: 'a "userId": x' },
|
||||
],
|
||||
profile: { userId: "cerased0000000000000000", name: "ä ö ü 🫀 } ] , :" },
|
||||
notes: ["userId", "brackets ] } [ {"],
|
||||
};
|
||||
|
||||
describe("rewriteBackupOwner", () => {
|
||||
it("replaces only the top-level owner, at every chunk size", async () => {
|
||||
const text = JSON.stringify(NESTED, null, 1);
|
||||
for (const sizes of [[1], [2], [3], [5, 1, 9], [64], [text.length]]) {
|
||||
const out = await run(text, "ctarget0000000000000000", sizes);
|
||||
expect(out.declared).toBe("cerased0000000000000000");
|
||||
expect(JSON.parse(out.text)).toEqual({
|
||||
...NESTED,
|
||||
userId: "ctarget0000000000000000",
|
||||
});
|
||||
// Outside the value, the bytes are the input's.
|
||||
expect(out.text).toBe(
|
||||
text.replace(
|
||||
'"userId": "cerased0000000000000000"',
|
||||
'"userId": "ctarget0000000000000000"',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads an escaped spelling of the key as the key", async () => {
|
||||
const text = '{"user\\u0049d":"cold","a":[1,{"userId":"keep"}]}';
|
||||
const out = await run(text, "cnew", [1]);
|
||||
expect(out.declared).toBe("cold");
|
||||
expect(JSON.parse(out.text)).toEqual({
|
||||
userId: "cnew",
|
||||
a: [1, { userId: "keep" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces every top-level owner and declares the last, as JSON.parse keeps it", async () => {
|
||||
const text = '{"userId":"first","x":1,"userId":"second"}';
|
||||
const out = await run(text, "cnew", [4]);
|
||||
expect(out.declared).toBe(JSON.parse(text).userId);
|
||||
expect(out.text).toBe('{"userId":"cnew","x":1,"userId":"cnew"}');
|
||||
});
|
||||
|
||||
it("leaves a file without an owner unchanged and declares none", async () => {
|
||||
const text = '{"schemaVersion":"2","rows":[{"userId":"nested"}]}';
|
||||
const out = await run(text, "cnew", [3]);
|
||||
expect(out.declared).toBeNull();
|
||||
expect(out.text).toBe(text);
|
||||
});
|
||||
|
||||
it("leaves a value that is a string elsewhere, or not a string, alone", async () => {
|
||||
const text = '{"name":"userId","userId":42}';
|
||||
const out = await run(text, "cnew", [2]);
|
||||
expect(out.declared).toBeNull();
|
||||
expect(out.text).toBe(text);
|
||||
});
|
||||
|
||||
it("leaves a top-level array alone", async () => {
|
||||
const text = '["userId","x"]';
|
||||
const out = await run(text, "cnew");
|
||||
expect(out.declared).toBeNull();
|
||||
expect(out.text).toBe(text);
|
||||
});
|
||||
|
||||
it("refuses an owner too long to be an id", async () => {
|
||||
const text = JSON.stringify({ userId: "x".repeat(4096) });
|
||||
await expect(run(text, "cnew", [100])).rejects.toBeInstanceOf(
|
||||
BackupOwnerRewriteError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a top-level key that is not valid JSON", async () => {
|
||||
await expect(run('{"bad\\q":1}', "cnew")).rejects.toBeInstanceOf(
|
||||
BackupJsonError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
classifyRestoreFailure,
|
||||
isPrimaryKeyConflict,
|
||||
RESTORE_FAILURE_CAUSE_MESSAGES,
|
||||
RESTORE_FAILURE_CAUSES,
|
||||
} from "@/lib/export/restore-failure-cause";
|
||||
@@ -147,3 +148,43 @@ describe("classifyRestoreFailure", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPrimaryKeyConflict", () => {
|
||||
it("names a duplicate primary key, as the driver reports it", () => {
|
||||
expect(
|
||||
isPrimaryKeyConflict(
|
||||
adapterError("P2010", "23505", {
|
||||
kind: "UniqueConstraintViolation",
|
||||
constraint: { index: "measurements_pkey" },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
// Without the structured name, read from the message.
|
||||
expect(
|
||||
isPrimaryKeyConflict(
|
||||
adapterError("P2010", "23505", {
|
||||
originalMessage:
|
||||
'duplicate key value violates unique constraint "mood_entries_pkey"',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not name another unique rule, or another failure", () => {
|
||||
expect(
|
||||
isPrimaryKeyConflict(
|
||||
adapterError("P2002", "23505", {
|
||||
constraint: { index: "measurements_user_id_external_id_key" },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPrimaryKeyConflict(
|
||||
adapterError("P2010", "23503", {
|
||||
constraint: { index: "measurements_pkey" },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isPrimaryKeyConflict(new Error("measurements_pkey"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The emptiness check behind restoring an erased account's copy into another
|
||||
* account reads every backed-up model by its `userId`, except the children
|
||||
* that have none, which it reaches through their parent. Both halves are held
|
||||
* to `prisma/schema.prisma` here, so a backed-up model added later is either
|
||||
* checked or fails this file; it cannot be skipped and read as empty.
|
||||
*
|
||||
* Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first
|
||||
* case names it (no `userId`); add an owned model to it and the second does.
|
||||
* Add a model with many rows per account (`Measurement`) to the setup list
|
||||
* and the third does.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
parseWipeSchema,
|
||||
WIPE_SCHEMA_PATH,
|
||||
} from "@/__tests__/helpers/wipe-schema-shape";
|
||||
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
|
||||
import {
|
||||
BACKED_UP_CHILD_MODELS,
|
||||
RESTORE_TARGET_OWNED_MODELS,
|
||||
RESTORE_TARGET_SETUP_MODELS,
|
||||
} from "@/lib/export/restore-target";
|
||||
|
||||
const models = parseWipeSchema();
|
||||
const SCHEMA = readFileSync(WIPE_SCHEMA_PATH, "utf8");
|
||||
|
||||
describe("restore target models", () => {
|
||||
it("reads every owned backed-up model by its userId", () => {
|
||||
const missing = RESTORE_TARGET_OWNED_MODELS.filter(
|
||||
(model) => !models.get(model)?.scalars.includes("userId"),
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
expect(RESTORE_TARGET_OWNED_MODELS.length).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
it("reaches each child through a required cascade to an owned parent", () => {
|
||||
for (const [child, parent] of Object.entries(BACKED_UP_CHILD_MODELS)) {
|
||||
expect(BACKED_UP_MODELS, child).toContain(child);
|
||||
const shape = models.get(child);
|
||||
expect(shape?.scalars, child).not.toContain("userId");
|
||||
expect(
|
||||
shape?.relations.some(
|
||||
(r) => r.target === parent && r.cascade && !r.optional,
|
||||
),
|
||||
`${child} -> ${parent}`,
|
||||
).toBe(true);
|
||||
expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves out only one-row-per-account setup state that no other backed-up model hangs off", () => {
|
||||
const setup = Object.keys(RESTORE_TARGET_SETUP_MODELS);
|
||||
expect(setup.sort()).toEqual(["CycleProfile", "OnboardingRecord"]);
|
||||
for (const model of setup) {
|
||||
expect(RESTORE_TARGET_OWNED_MODELS, model).toContain(model);
|
||||
// One row per account: a list of things the person recorded is not
|
||||
// setup state.
|
||||
const body = SCHEMA.match(
|
||||
new RegExp(`^model ${model} \\{([\\s\\S]*?)^\\}`, "m"),
|
||||
)?.[1];
|
||||
expect(
|
||||
/^\s*userId\s+String\s+@unique/m.test(body ?? "") ||
|
||||
/@@unique\(\[userId\]\)/.test(body ?? ""),
|
||||
model,
|
||||
).toBe(true);
|
||||
// Nothing backed up hangs off it, so leaving it out of the check
|
||||
// leaves no record unchecked behind it.
|
||||
for (const other of BACKED_UP_MODELS) {
|
||||
expect(
|
||||
models.get(other)?.relations.some((r) => r.target === model),
|
||||
`${other} -> ${model}`,
|
||||
).not.toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,15 @@ export interface AccountSettingsRestoreInput {
|
||||
accountSettings?: AccountSettingsBackupEntry | null;
|
||||
}
|
||||
|
||||
/** Where the copy being restored came from. */
|
||||
export interface AccountSettingsRestoreSource {
|
||||
/**
|
||||
* The copy was uploaded into this account from a file another account
|
||||
* wrote (`backup-upload-type.ts`), so its settings are that author's word.
|
||||
*/
|
||||
retargeted: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSettingsRestoreResult {
|
||||
/** How many setting columns were written. */
|
||||
applied: number;
|
||||
@@ -32,11 +41,16 @@ export interface AccountSettingsRestoreResult {
|
||||
* the row are never in the write, so they stay exactly as the receiving
|
||||
* account has them. A file without the section leaves the row untouched,
|
||||
* which is how every backup written before the section existed restores.
|
||||
*
|
||||
* An admin's own copy is the operator's configuration and is read under the
|
||||
* operator's AI endpoint policy. A retargeted copy never is: the admin chose
|
||||
* where it went, but the file's author wrote its settings.
|
||||
*/
|
||||
export async function restoreAccountSettings(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: AccountSettingsRestoreInput,
|
||||
source: AccountSettingsRestoreSource,
|
||||
): Promise<AccountSettingsRestoreResult> {
|
||||
const entry = payload.accountSettings;
|
||||
if (!entry) return { applied: 0, refused: [], unitsChanged: false };
|
||||
@@ -52,7 +66,7 @@ export async function restoreAccountSettings(
|
||||
});
|
||||
const { data, refused } = admitAccountSettings(
|
||||
entry,
|
||||
{ operatorTrusted: current.role === "ADMIN" },
|
||||
{ operatorTrusted: !source.retargeted && current.role === "ADMIN" },
|
||||
current.aiProvider,
|
||||
);
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ export const RESTORE_STEPS: readonly RestoreStep[] = [
|
||||
ctx.tx,
|
||||
ctx.ownerId,
|
||||
ctx.payload,
|
||||
{ retargeted: ctx.retargeted },
|
||||
);
|
||||
for (const column of result.refused) {
|
||||
ctx.skips.push({ catalogue: "accountSetting", key: column, links: 1 });
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Point a backup file at another account, on its way in.
|
||||
*
|
||||
* A backup names its owner once, in the top-level `userId` member. The restore
|
||||
* takes the account it writes from there and from nowhere else
|
||||
* (`checkOwnership` in `restore-preflight.ts`), and every domain writes its
|
||||
* rows under that account (`ctx.ownerId`), never under an id read from a row.
|
||||
* So the copy of an account that has since been erased goes into another
|
||||
* account by changing that one member, and nothing else in the file: the copy
|
||||
* stored is then that account's copy in every respect (its preview, its
|
||||
* download, its restore), and the erased id is stored nowhere, not even inside
|
||||
* the encrypted copy.
|
||||
*
|
||||
* This rewrites the member as the bytes stream past, without holding the
|
||||
* document (a disaster-recovery file can be longer than any string V8 can
|
||||
* hold, see `backup-json-scan.ts`). It tracks only string and nesting state,
|
||||
* and parses only the top-level keys and the one value it replaces. A key is
|
||||
* compared after `JSON.parse`, so an escaped spelling of `userId` is the same
|
||||
* key, exactly as the reader behind it sees it; a `userId` nested anywhere
|
||||
* below the top level is left alone. Every other byte passes through
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
import { BackupJsonError } from "@/lib/export/backup-json-scan";
|
||||
|
||||
const QUOTE = 0x22;
|
||||
const BACKSLASH = 0x5c;
|
||||
const OPEN_BRACE = 0x7b;
|
||||
const CLOSE_BRACE = 0x7d;
|
||||
const OPEN_BRACKET = 0x5b;
|
||||
const CLOSE_BRACKET = 0x5d;
|
||||
const COMMA = 0x2c;
|
||||
|
||||
/** Longer than any top-level key a backup carries. */
|
||||
const MAX_KEY_BYTES = 256;
|
||||
/** Far longer than any account id; a longer owner is not an id. */
|
||||
const MAX_OWNER_BYTES = 1024;
|
||||
|
||||
/** The file's owner member is not something this can rewrite. */
|
||||
export class BackupOwnerRewriteError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BackupOwnerRewriteError";
|
||||
}
|
||||
}
|
||||
|
||||
type StringRole = "key" | "owner" | "other";
|
||||
|
||||
export interface BackupOwnerRewrite {
|
||||
/** The bytes, with every top-level `userId` string set to the target. */
|
||||
bytes: AsyncGenerator<Buffer>;
|
||||
/**
|
||||
* The owner the file declared, once the stream has passed it: the last
|
||||
* top-level `userId` string, which is the one `JSON.parse` keeps. Null when
|
||||
* the file has none.
|
||||
*/
|
||||
declaredOwner(): string | null;
|
||||
}
|
||||
|
||||
export function rewriteBackupOwner(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
targetId: string,
|
||||
): BackupOwnerRewrite {
|
||||
const replacement = Buffer.from(JSON.stringify(targetId), "utf8");
|
||||
let declared: string | null = null;
|
||||
|
||||
async function* bytes(): AsyncGenerator<Buffer> {
|
||||
let depth = 0;
|
||||
let topIsObject = false;
|
||||
let expectKey = false;
|
||||
let lastKey: string | null = null;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let role: StringRole = "other";
|
||||
// The key or owner string being collected, across chunks.
|
||||
let collected: Buffer[] = [];
|
||||
let collectedBytes = 0;
|
||||
|
||||
for await (const raw of source) {
|
||||
const chunk = Buffer.isBuffer(raw)
|
||||
? raw
|
||||
: Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
|
||||
const out: Buffer[] = [];
|
||||
// Where the bytes passed through unchanged start in this chunk; -1
|
||||
// while the owner value is being withheld.
|
||||
let passFrom = role === "owner" && inString ? -1 : 0;
|
||||
// Where the string being collected starts in this chunk.
|
||||
let collectFrom = 0;
|
||||
|
||||
const collect = (to: number) => {
|
||||
if (to > collectFrom) {
|
||||
collected.push(chunk.subarray(collectFrom, to));
|
||||
collectedBytes += to - collectFrom;
|
||||
}
|
||||
if (role === "key" && collectedBytes > MAX_KEY_BYTES) {
|
||||
// Not a key this rewrite cares about; stop collecting it.
|
||||
role = "other";
|
||||
collected = [];
|
||||
collectedBytes = 0;
|
||||
}
|
||||
if (role === "owner" && collectedBytes > MAX_OWNER_BYTES) {
|
||||
throw new BackupOwnerRewriteError(
|
||||
"The file's owner is too long to be an account id",
|
||||
);
|
||||
}
|
||||
};
|
||||
const take = (): string => {
|
||||
const text = Buffer.concat(collected, collectedBytes).toString("utf8");
|
||||
collected = [];
|
||||
collectedBytes = 0;
|
||||
try {
|
||||
return JSON.parse(text) as string;
|
||||
} catch {
|
||||
throw new BackupJsonError(
|
||||
"A top-level string in the file is not valid JSON",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const byte = chunk[i]!;
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (byte === BACKSLASH) escaped = true;
|
||||
else if (byte === QUOTE) {
|
||||
inString = false;
|
||||
if (role === "key") {
|
||||
collect(i + 1);
|
||||
lastKey = role === "key" ? take() : null;
|
||||
expectKey = false;
|
||||
} else if (role === "owner") {
|
||||
collect(i + 1);
|
||||
declared = take();
|
||||
out.push(replacement);
|
||||
passFrom = i + 1;
|
||||
}
|
||||
role = "other";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte === QUOTE) {
|
||||
inString = true;
|
||||
if (depth === 1 && topIsObject && expectKey) {
|
||||
role = "key";
|
||||
collectFrom = i;
|
||||
} else if (depth === 1 && topIsObject && lastKey === "userId") {
|
||||
role = "owner";
|
||||
collectFrom = i;
|
||||
if (passFrom >= 0) out.push(chunk.subarray(passFrom, i));
|
||||
passFrom = -1;
|
||||
} else {
|
||||
role = "other";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte === OPEN_BRACE || byte === OPEN_BRACKET) {
|
||||
depth += 1;
|
||||
if (depth === 1) {
|
||||
topIsObject = byte === OPEN_BRACE;
|
||||
expectKey = topIsObject;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (byte === COMMA && depth === 1) {
|
||||
expectKey = topIsObject;
|
||||
lastKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (inString && (role === "key" || role === "owner")) {
|
||||
collect(chunk.length);
|
||||
}
|
||||
if (passFrom >= 0) out.push(chunk.subarray(passFrom));
|
||||
const piece = out.length === 1 ? out[0]! : Buffer.concat(out);
|
||||
if (piece.byteLength > 0) yield piece;
|
||||
}
|
||||
}
|
||||
|
||||
return { bytes: bytes(), declaredOwner: () => declared };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The `DataBackup.type` of a copy an admin uploaded.
|
||||
*
|
||||
* A file uploaded for the account it names is `MANUAL_UPLOAD_<ms>`. A file
|
||||
* uploaded into another account (the copy of an erased account, put into the
|
||||
* account chosen for it) is `RETARGETED_UPLOAD_<ms>`, and the mark stays with
|
||||
* the stored copy: its settings were written by the file's author, not by the
|
||||
* account it now belongs to, so its restore never takes them on the
|
||||
* operator's word, whatever that account's role is by then. The millisecond
|
||||
* suffix keeps the `(userId, type)` unique constraint free for several
|
||||
* uploads beside the rolling `WEEKLY_AUTO` copy.
|
||||
*/
|
||||
const MANUAL_UPLOAD_PREFIX = "MANUAL_UPLOAD_";
|
||||
const RETARGETED_UPLOAD_PREFIX = "RETARGETED_UPLOAD_";
|
||||
|
||||
export function uploadedBackupType(retargeted: boolean, now: number): string {
|
||||
return `${retargeted ? RETARGETED_UPLOAD_PREFIX : MANUAL_UPLOAD_PREFIX}${now}`;
|
||||
}
|
||||
|
||||
/** Whether a stored copy was uploaded into an account other than its own. */
|
||||
export function isRetargetedUploadType(type: string): boolean {
|
||||
return type.startsWith(RETARGETED_UPLOAD_PREFIX);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { AccountSettingsRestoreResult } from "@/lib/export/account-settings-restore";
|
||||
import { isRetargetedUploadType } from "@/lib/export/backup-upload-type";
|
||||
import {
|
||||
RESTORE_CLEAR_STEPS,
|
||||
RESTORE_STEPS,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
import {
|
||||
classifyRestoreFailure,
|
||||
RESTORE_FAILURE_CAUSE_MESSAGES,
|
||||
isPrimaryKeyConflict,
|
||||
} from "@/lib/export/restore-failure-cause";
|
||||
import { takeRestoreLock } from "@/lib/export/restore-lock";
|
||||
import {
|
||||
@@ -134,6 +136,9 @@ export type RestoreProgressSink = (
|
||||
* before the first delete or rolls the whole transaction back, so each means
|
||||
* the account is unchanged.
|
||||
*/
|
||||
/** A retargeted copy whose rows another account already holds. */
|
||||
const ROWS_EXIST_ELSEWHERE_CODE = "backup.restore.rows_exist_elsewhere";
|
||||
|
||||
export type RestoreFailureCode =
|
||||
| "backup_not_found"
|
||||
| "backup_changed"
|
||||
@@ -146,6 +151,7 @@ export type RestoreFailureCode =
|
||||
| "document_ciphertext_missing"
|
||||
| "backup.key.missing"
|
||||
| "backup.foreign_reference"
|
||||
| "backup.restore.rows_exist_elsewhere"
|
||||
| "time_budget"
|
||||
| "transaction_failed"
|
||||
| "interrupted"
|
||||
@@ -259,6 +265,7 @@ async function replaceAccount(
|
||||
read: ReadBackup,
|
||||
ownerId: string,
|
||||
restoreInstanceSettings: boolean,
|
||||
retargeted: boolean,
|
||||
transactionTimeoutMs: number,
|
||||
tracker: RestoreProgressTracker,
|
||||
prepass: MeasurementRestorePrepass,
|
||||
@@ -285,6 +292,7 @@ async function replaceAccount(
|
||||
streamed: read.streamed,
|
||||
skips,
|
||||
restoreInstanceSettings,
|
||||
retargeted,
|
||||
measurementPrepass: prepass,
|
||||
links: emptyRestoreLinks(),
|
||||
outputs: {
|
||||
@@ -342,6 +350,7 @@ async function transactionRefusal(
|
||||
input: RestoreBackupInput,
|
||||
ownerId: string,
|
||||
transactionTimeoutMs: number,
|
||||
retargeted: boolean,
|
||||
err: unknown,
|
||||
): Promise<RestoreBackupOutcome> {
|
||||
const verbose = err instanceof Error ? err.message : String(err);
|
||||
@@ -372,6 +381,16 @@ async function transactionRefusal(
|
||||
references: err.edges,
|
||||
});
|
||||
}
|
||||
if (retargeted && isPrimaryKeyConflict(err)) {
|
||||
// Rows keep their ids from the file, so the copy of an erased account
|
||||
// can live in one account only. Rolled back, so nothing was changed.
|
||||
return refused(
|
||||
409,
|
||||
ROWS_EXIST_ELSEWHERE_CODE,
|
||||
"Records in this copy are already in this database, so the file was most likely already restored into another account. A file can be restored into one account only. Nothing was changed.",
|
||||
{ errorCode: ROWS_EXIST_ELSEWHERE_CODE },
|
||||
);
|
||||
}
|
||||
if (isStoredBackupReadError(err)) {
|
||||
// The second read, inside the transaction, found the copy replaced or
|
||||
// altered. The transaction rolled back, so nothing was changed.
|
||||
@@ -592,6 +611,12 @@ export async function restoreBackup(
|
||||
const checked = await checkBackupBeforeRestore(input, read);
|
||||
if (!checked.ok) return checked.outcome;
|
||||
const { owner, transactionTimeoutMs } = checked;
|
||||
// Read from the row, not passed in, so no caller can leave it out.
|
||||
const stored = await prisma.dataBackup.findUnique({
|
||||
where: { id: backup.id },
|
||||
select: { type: true },
|
||||
});
|
||||
const retargeted = isRetargetedUploadType(stored?.type ?? "");
|
||||
|
||||
let result: TransactionResult;
|
||||
try {
|
||||
@@ -599,12 +624,19 @@ export async function restoreBackup(
|
||||
read,
|
||||
owner.id,
|
||||
restoreInstanceSettings,
|
||||
retargeted,
|
||||
transactionTimeoutMs,
|
||||
tracker,
|
||||
prepass,
|
||||
);
|
||||
} catch (err) {
|
||||
return transactionRefusal(input, owner.id, transactionTimeoutMs, err);
|
||||
return transactionRefusal(
|
||||
input,
|
||||
owner.id,
|
||||
transactionTimeoutMs,
|
||||
retargeted,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await afterCommit(input, read, owner, result, tracker);
|
||||
|
||||
@@ -179,6 +179,11 @@ export interface RestoreContext {
|
||||
/** Declared inside the transaction, so a rollback takes the report with it. */
|
||||
readonly skips: RestoreSkipLog;
|
||||
readonly restoreInstanceSettings: boolean;
|
||||
/**
|
||||
* The copy was uploaded into this account from another account's file
|
||||
* (`backup-upload-type.ts`); its settings are not the operator's word.
|
||||
*/
|
||||
readonly retargeted: boolean;
|
||||
/** What the file's first read noted about its readings. */
|
||||
readonly measurementPrepass: MeasurementRestorePrepass;
|
||||
readonly links: RestoreLinks;
|
||||
|
||||
@@ -108,6 +108,25 @@ export function classifyRestoreFailure(err: unknown): ClassifiedRestoreFailure {
|
||||
return { cause: "other", code: prisma };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a restore failed because a row it inserted carries a primary key
|
||||
* already taken. The restore deletes the receiving account's own rows first,
|
||||
* so the key belongs to a row of another account: for a copy put into a
|
||||
* chosen account, the same file restored into a different account before.
|
||||
*/
|
||||
export function isPrimaryKeyConflict(err: unknown): boolean {
|
||||
if (sqlState(err) !== "23505") return false;
|
||||
const cause = field(field(field(err, "meta"), "driverAdapterError"), "cause");
|
||||
const index = field(field(cause, "constraint"), "index");
|
||||
const name =
|
||||
typeof index === "string"
|
||||
? index
|
||||
: /unique constraint "([^"]+)"/.exec(
|
||||
String(field(cause, "originalMessage") ?? field(err, "message")),
|
||||
)?.[1];
|
||||
return typeof name === "string" && name.endsWith("_pkey");
|
||||
}
|
||||
|
||||
/** The sentence the job row and the audit trail carry for each cause. */
|
||||
export const RESTORE_FAILURE_CAUSE_MESSAGES: Record<
|
||||
RestoreFailureCause,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Which account the copy of an erased account may be restored into.
|
||||
*
|
||||
* A restore replaces: every row of the account in a section the copy carries
|
||||
* is deleted and the copy's rows are written in their place
|
||||
* (`docs/ops/backup-restore.md`). There is no merge. So putting the record of
|
||||
* an account that no longer exists into another account is only safe when
|
||||
* that account holds none of those rows, and the check is exactly that: a row
|
||||
* of any model the backup carries ({@link BACKED_UP_MODELS}) under the
|
||||
* account refuses it. Everything a restore leaves alone (credentials,
|
||||
* sessions, devices, grants) does not count, and neither does the setup state
|
||||
* signing up leaves behind ({@link RESTORE_TARGET_SETUP_MODELS}): the person
|
||||
* did not enter it as a record, and the restore replaces it with the copy's.
|
||||
* A reading or a medication entered during setup is a record like any other
|
||||
* and still refuses, because the restore would delete it.
|
||||
*
|
||||
* The other refusals keep the account rules intact. A managed profile is
|
||||
* its Guardians' record, which an admin does not write into (the users list
|
||||
* offers no admin deletion on one for the same reason). An account pending
|
||||
* deletion is locked and will be erased, with whatever was restored into it.
|
||||
* An admin account is the operator's: its settings are taken on the
|
||||
* operator's word (a wider AI endpoint policy) and its role reaches every
|
||||
* account, so another person's record does not go into it. Someone who has
|
||||
* signed up again holds an ordinary account, and a new one can always be
|
||||
* made. Neither the restore nor this check touches the role, so the
|
||||
* last-admin rule is not in play.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { BACKED_UP_MODELS } from "@/lib/export/backup-plan";
|
||||
|
||||
/**
|
||||
* The backed-up models with no owner column of their own, each with the owned
|
||||
* model it hangs off through a required cascade. A row of one cannot exist
|
||||
* without a row of its parent, so checking the parent checks it.
|
||||
* `restore-target-models.test.ts` holds this list to the schema.
|
||||
*/
|
||||
export const BACKED_UP_CHILD_MODELS: Readonly<Record<string, string>> = {
|
||||
MedicationSchedule: "Medication",
|
||||
MedicationScheduleRevision: "Medication",
|
||||
MedicationDoseChange: "Medication",
|
||||
MedicationEfficacyTarget: "Medication",
|
||||
MedicationInventoryEvent: "Medication",
|
||||
MedicationCategoryAssignment: "Medication",
|
||||
ReminderPhaseConfig: "Medication",
|
||||
MoodEntryTagLink: "MoodEntry",
|
||||
IllnessSymptomLink: "IllnessDayLog",
|
||||
CycleSymptomLink: "CycleDayLog",
|
||||
WorkoutRoute: "Workout",
|
||||
WorkoutSamples: "Workout",
|
||||
CoachMessage: "CoachConversation",
|
||||
CoachConversationDocument: "CoachConversation",
|
||||
};
|
||||
|
||||
/** Every backed-up model that carries `userId`, in the plan's order. */
|
||||
export const RESTORE_TARGET_OWNED_MODELS: readonly string[] =
|
||||
BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS));
|
||||
|
||||
/**
|
||||
* The backed-up models an account holds a row of after signing up and going
|
||||
* through setup, before anything has been recorded, each with the writer.
|
||||
* One row per account, settings-shaped, and deleted and re-created from the
|
||||
* copy by every restore, so the copy's state replaces it and nothing the
|
||||
* person entered is lost. Without this list the account of someone who has
|
||||
* signed up again was always refused, which is the case the target exists
|
||||
* for. `restore-target-models.test.ts` holds each to that shape.
|
||||
*/
|
||||
export const RESTORE_TARGET_SETUP_MODELS: Readonly<Record<string, string>> = {
|
||||
// `PATCH /api/onboarding/answers` and `POST /api/onboarding/complete`.
|
||||
OnboardingRecord: "the setup answers",
|
||||
// Created by the setup confirm when the cycle area is chosen, and by the
|
||||
// first read of the cycle settings.
|
||||
CycleProfile: "the cycle settings",
|
||||
};
|
||||
|
||||
/** The models whose rows make an account a refused restore target. */
|
||||
const RESTORE_TARGET_CHECKED_MODELS: readonly string[] =
|
||||
RESTORE_TARGET_OWNED_MODELS.filter(
|
||||
(model) => !(model in RESTORE_TARGET_SETUP_MODELS),
|
||||
);
|
||||
|
||||
interface OwnedDelegate {
|
||||
findFirst(args: {
|
||||
where: { userId: string };
|
||||
select: { userId: true };
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
function ownedDelegate(db: object, model: string): OwnedDelegate {
|
||||
const key = model.charAt(0).toLowerCase() + model.slice(1);
|
||||
const delegate = (db as Record<string, OwnedDelegate | undefined>)[key];
|
||||
if (!delegate || typeof delegate.findFirst !== "function") {
|
||||
// Never narrower than the plan: a model this cannot read is a model it
|
||||
// would otherwise call empty.
|
||||
throw new Error(`No Prisma delegate for backed-up model "${model}"`);
|
||||
}
|
||||
return delegate;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backed-up models under which `userId` holds at least one row, setup
|
||||
* state left out.
|
||||
*/
|
||||
export async function modelsHoldingRecords(
|
||||
db: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const held: string[] = [];
|
||||
for (const model of RESTORE_TARGET_CHECKED_MODELS) {
|
||||
const row = await ownedDelegate(db, model).findFirst({
|
||||
where: { userId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (row) held.push(model);
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
export type RestoreTargetRefusal =
|
||||
| "not_found"
|
||||
| "managed_profile"
|
||||
| "pending_deletion"
|
||||
| "admin_account"
|
||||
| "holds_records";
|
||||
|
||||
export type RestoreTargetVerdict =
|
||||
| { ok: true; target: { id: string; username: string } }
|
||||
| {
|
||||
ok: false;
|
||||
reason: RestoreTargetRefusal;
|
||||
target: { id: string; username: string } | null;
|
||||
/** For `holds_records`: the models that hold rows, in plan order. */
|
||||
models: string[];
|
||||
};
|
||||
|
||||
/** Whether the copy of an erased account may go into `userId`. */
|
||||
export async function checkRestoreTarget(
|
||||
db: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<RestoreTargetVerdict> {
|
||||
const row = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
managedProfileAt: true,
|
||||
deletionRequestedAt: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
if (!row) {
|
||||
return { ok: false, reason: "not_found", target: null, models: [] };
|
||||
}
|
||||
const target = { id: row.id, username: row.username };
|
||||
if (row.managedProfileAt) {
|
||||
return { ok: false, reason: "managed_profile", target, models: [] };
|
||||
}
|
||||
if (row.deletionRequestedAt) {
|
||||
return { ok: false, reason: "pending_deletion", target, models: [] };
|
||||
}
|
||||
if (row.role === "ADMIN") {
|
||||
return { ok: false, reason: "admin_account", target, models: [] };
|
||||
}
|
||||
const models = await modelsHoldingRecords(db, row.id);
|
||||
if (models.length > 0) {
|
||||
return { ok: false, reason: "holds_records", target, models };
|
||||
}
|
||||
return { ok: true, target };
|
||||
}
|
||||
|
||||
/** The wire code of each refusal. */
|
||||
export const BACKUP_TARGET_REFUSAL_CODES = {
|
||||
not_found: "backup.target.not_found",
|
||||
managed_profile: "backup.target.managed_profile",
|
||||
pending_deletion: "backup.target.pending_deletion",
|
||||
admin_account: "backup.target.admin_account",
|
||||
holds_records: "backup.target.holds_records",
|
||||
} as const satisfies Record<RestoreTargetRefusal, string>;
|
||||
|
||||
/** The status and stable code each refusal answers with. */
|
||||
export const RESTORE_TARGET_REFUSALS: Readonly<
|
||||
Record<
|
||||
RestoreTargetRefusal,
|
||||
{ status: number; code: string; message: string }
|
||||
>
|
||||
> = {
|
||||
not_found: {
|
||||
status: 404,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.not_found,
|
||||
message: "The chosen account does not exist.",
|
||||
},
|
||||
managed_profile: {
|
||||
status: 409,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.managed_profile,
|
||||
message:
|
||||
"The chosen account is a managed profile. Its Guardians run it, so a copy cannot be restored into it.",
|
||||
},
|
||||
pending_deletion: {
|
||||
status: 409,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.pending_deletion,
|
||||
message:
|
||||
"The chosen account is pending deletion. Undo the deletion first, or choose another account.",
|
||||
},
|
||||
admin_account: {
|
||||
status: 409,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.admin_account,
|
||||
message:
|
||||
"The chosen account is an admin account. A copy of another person's record cannot be restored into it; choose an ordinary account or create a new one.",
|
||||
},
|
||||
holds_records: {
|
||||
status: 409,
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.holds_records,
|
||||
message:
|
||||
"The chosen account already holds records, and a restore replaces them rather than adding to them. Choose an empty account or create a new one.",
|
||||
},
|
||||
};
|
||||
@@ -39,6 +39,7 @@ const CODE_TABLE: Record<BackupRestoreFailureCode, true> = {
|
||||
document_ciphertext_missing: true,
|
||||
"backup.key.missing": true,
|
||||
"backup.foreign_reference": true,
|
||||
"backup.restore.rows_exist_elsewhere": true,
|
||||
time_budget: true,
|
||||
transaction_failed: true,
|
||||
interrupted: true,
|
||||
|
||||
@@ -18,9 +18,6 @@ vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-client", () => ({
|
||||
resolveCodexVisionSlug: vi.fn(() => "gpt-5"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -154,6 +154,7 @@ describe("Codex", () => {
|
||||
it("asks for no reasoning instead of the backend's default", async () => {
|
||||
const body = await sentBody(
|
||||
new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi.fn(),
|
||||
@@ -175,6 +176,7 @@ describe("Codex", () => {
|
||||
),
|
||||
);
|
||||
const err = await new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi.fn(),
|
||||
@@ -202,6 +204,7 @@ describe("Codex", () => {
|
||||
),
|
||||
);
|
||||
const err = await new CodexClient({
|
||||
credentialKey: "user:test",
|
||||
accessToken: "t",
|
||||
accountId: "a",
|
||||
onTokenRefresh: vi.fn(),
|
||||
|
||||
@@ -18,13 +18,12 @@ import {
|
||||
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { resolveCodexVisionSlug } from "@/lib/ai/codex-client";
|
||||
import { chainEntryReadsImages } from "@/lib/ai/codex-slug-chain";
|
||||
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
supportsPdfForProvider,
|
||||
supportsVisionForConfig,
|
||||
type VisionProviderType,
|
||||
} from "@/lib/ai/vision-capability";
|
||||
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
|
||||
@@ -36,19 +35,19 @@ interface ModelContext {
|
||||
adminModel: string | null;
|
||||
}
|
||||
|
||||
/** The model that drives a chain entry's vision allowlist. */
|
||||
function modelForEntry(
|
||||
/** Whether a chain entry's model can read an image. */
|
||||
function entryReadsImages(
|
||||
providerType: ProviderChainResolved["providerType"],
|
||||
userId: string,
|
||||
ctx: ModelContext,
|
||||
): string | null {
|
||||
// Codex resolves its working slug at request time from the OAuth slug
|
||||
// fallback chain — NOT from the user's `aiModel`. Use the cached/chain-head
|
||||
// slug so the codex vision gate tests the model that actually runs. The
|
||||
// operator-shared central Codex (`admin-codex`) rides the same slug chain.
|
||||
if (providerType === "codex" || providerType === "admin-codex") {
|
||||
return resolveCodexVisionSlug();
|
||||
}
|
||||
return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
|
||||
): boolean {
|
||||
// Codex picks its model at request time from the sign-in's working slug
|
||||
// and the models its account is offered, NOT from the user's `aiModel`.
|
||||
// Each Codex path asks its own sign-in: the person's connection, or the
|
||||
// operator-shared central one (`admin-codex`).
|
||||
const model =
|
||||
providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
|
||||
return chainEntryReadsImages(providerType, userId, model);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,8 +125,7 @@ export async function resolveVisionProvider(
|
||||
|
||||
for (const entry of chain) {
|
||||
const providerType = entry.providerType as VisionProviderType;
|
||||
const model = modelForEntry(entry.providerType, ctx);
|
||||
if (supportsVisionForConfig(providerType, model)) {
|
||||
if (entryReadsImages(entry.providerType, userId, ctx)) {
|
||||
return {
|
||||
chain,
|
||||
localOcrEnabled,
|
||||
|
||||
@@ -100,9 +100,19 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
backup: [
|
||||
"backup.foreign_reference",
|
||||
"backup.key.missing",
|
||||
"backup.owner.exists",
|
||||
"backup.owner.missing",
|
||||
"backup.payload.undecryptable",
|
||||
"backup.restore.active",
|
||||
"backup.restore.rows_exist_elsewhere",
|
||||
"backup.section.missing",
|
||||
"backup.target.admin_account",
|
||||
"backup.target.holds_records",
|
||||
"backup.target.managed_profile",
|
||||
"backup.target.not_found",
|
||||
"backup.target.pending_deletion",
|
||||
"backup.target.unconfirmed",
|
||||
"backup.target.username_taken",
|
||||
"backup.upload.too_large",
|
||||
],
|
||||
biomarkers: ["biomarkers.unit.locked"],
|
||||
|
||||
@@ -96,7 +96,7 @@ const restoreFailure = z
|
||||
.object({
|
||||
code: z.string().meta({
|
||||
description:
|
||||
"Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed.",
|
||||
"Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account already holds, because the same file was restored there before), `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed.",
|
||||
}),
|
||||
message: z.string(),
|
||||
sections: z.array(z.string()).optional(),
|
||||
@@ -218,7 +218,26 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Admin"],
|
||||
summary: "Store an uploaded backup file as a new copy",
|
||||
description:
|
||||
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
|
||||
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists here (erased, say) can be sent again with a target in the query: `into=existing&userId=<id>&confirm=<username>` for an account that holds no records yet, or `into=new&username=<name>` for a new account created for the copy, with no password and no email (an admin sets its first password with the existing password reset). The file's owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like any other; the erased account's id is not stored. A target is refused before the file is read when the account does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
into: z.enum(["existing", "new"]).optional().meta({
|
||||
description:
|
||||
"Where the copy goes when the account the file names does not exist here. Absent: the file's own account, which has to exist.",
|
||||
}),
|
||||
userId: z.string().max(64).optional().meta({
|
||||
description: "With `into=existing`: the account's id.",
|
||||
}),
|
||||
confirm: z.string().max(64).optional().meta({
|
||||
description:
|
||||
"With `into=existing`: the account's username, typed by the operator.",
|
||||
}),
|
||||
username: z.string().min(3).max(30).optional().meta({
|
||||
description:
|
||||
"With `into=new`: the new account's username (letters, digits, `_` and `-`).",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -261,6 +280,16 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The chosen target cannot take the copy, and nothing was stored: a managed profile (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an admin account (`backup.target.admin_account`), an account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username already taken (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description:
|
||||
"Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored.",
|
||||
@@ -268,7 +297,7 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here.",
|
||||
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here (`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose `confirm` is not the account's username (`backup.target.unconfirmed`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user