Merge pull request #660 from MBombeck/release/v1.33.1

v1.33.1 — what the app knew and did not say
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-27 18:01:38 +02:00
commit e21fc24950
146 files changed
+7487 -1367

No files matched your search

+3
View File
@@ -247,3 +247,6 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# -----------------------------------------------------------------------------
# Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress.
# IP_GEO_ALLOW_INSECURE="true"
#
# Directory the offline GeoLite2 databases are read from; default /opt/geolite2 (the path the image bakes its own copy into). Point it at a directory you mount yourself to bring your own MMDBs — see docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
+18
View File
@@ -293,6 +293,24 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
# IP_GEO_LOOKUP_DISABLED="1"
# -----------------------------------------------------------------------------
# Offline GeoLite2 databases -- optional
# -----------------------------------------------------------------------------
# Where the runtime looks for GeoLite2-City.mmdb and GeoLite2-ASN.mmdb.
# Unset it reads /opt/geolite2, the path the published image bakes its own
# copy into -- but only if the image build had a MaxMind licence key, so on
# a stock `:latest` the directory is usually empty and every login IP goes
# to the online provider instead.
#
# To resolve locations on your own host: create a free MaxMind account,
# pull both databases with `geoipupdate`, mount the directory into the
# container read-only (the commented `volumes:` block in docker-compose.yml
# shows the line), and point this at the container path. Pair it with
# IP_GEO_LOOKUP_DISABLED="1" above for zero IP egress. Refreshing the files
# on the host is enough -- the reader picks them up on the next container
# restart. See docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
# -----------------------------------------------------------------------------
# Environmental-context module (Open-Meteo) -- optional
# -----------------------------------------------------------------------------
+83
View File
@@ -2,6 +2,89 @@
## [Unreleased]
## [1.33.1] — 2026-07-27
A patch release about the same thing as the one before it: places where the
app knew something and said something else.
### Two that change what you receive
**Medication reminders arrived every fifteen minutes.** The rule that decides
whether a reminder has already been sent was keyed on a ledger that could not
tell one dose slot from another, so an overdue dose kept re-announcing itself
for as long as it stayed overdue. One account was getting roughly fifty mails
a day. A reminder now goes out once per dose, per escalation step, per local
day, and two doses due at the same time no longer replace each other on the
lock screen.
**"Handled by the app" now silences only the app.** The setting that lets the
iOS client take over medication reminders was suppressing every channel, so
switching it on quietly turned off Telegram, ntfy, webhooks, email and web
push as well. That was never what it said it did. If you have been using it as
a general mute, those channels will start delivering again, and the setting
does what its name says.
### Two that were reported
Someone self-hosting could not use an OpenAI-compatible gateway. LiteLLM,
OpenRouter and vLLM all speak the OpenAI wire, but the provider list had no
entry that honoured a base URL of your own, and an earlier answer from us said
otherwise. There is now a provider for exactly this, with its own key, model
and endpoint. The plain OpenAI entry stays pinned to OpenAI, which a test
enforces, so a key meant for one never travels to the other.
Timestamps followed the browser rather than the profile in four places. The
worst of them: a shared report downloaded as a PDF could carry a different
date than the page it was downloaded from, for the same reading. Charts also
drew their week and month boundaries in one fixed timezone, so an evening
reading landed in the wrong bar for anyone living elsewhere.
### The backup
Three things it carried and could not restore, or could not carry at all.
A cycle symptom you created yourself was never included, so on restore its
key resolved to nothing and the link was dropped without a word. Three lines
away in the same function, an unknown cycle reference threw an error. Same
kind of missing reference, two different answers.
A mood factor you created yourself had the opposite ending: the restore
looked only for the built-in ones, found your own missing, and stopped. The
file was complete and unusable.
Nutrient day totals have been written into every backup since v1.29 and were
discarded on restore, silently, while the restore reported success.
What a backup contains is now declared in one place, with a written reason
for every record type left out. Around thirty types are still carried by
neither the file nor the restore, and they are named there rather than left
to be discovered. The export card no longer promises "every record you own",
because it does not.
### For operators
Two migrations. `0280` adds the gateway provider's columns, `0281` turns the
integration ledger's single failing-part slot into a set. Neither deletes or
rewrites a measurement.
A provider that syncs several things separately can now remember every part
that is failing at once. Until now the last one to report overwrote the rest,
which is the limit named in the v1.33.0 notes and not fixed then.
Self-hosters can point `GEOLITE2_DIR` at their own GeoLite2 databases. The
variable was read by the code and missing from the compose whitelist, so it
never reached the container. The message in the admin area used to tell
self-hosters to set a secret in this repository, which is not something they
can do.
### Credit
Reported by @k-nacion (the OpenAI-compatible gateway and the timezone
rendering), @mathewcsims (the unreachable GeoLite2 setting, found by reading
the strings inside a running container), @Nazza01 (the dose-history import),
@doenke (the table under the chart, and for narrowing his own request until it
fit one release) and @lutzkind (the dashboard layout).
## [1.33.0] — 2026-07-26
Eight defects, and every one of them had the same shape: something failed
+1 -1
View File
@@ -154,7 +154,7 @@ These keep tripping operators; they're worth knowing before you push something t
- **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression.
- **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`.
- **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"<hex>","v2":"<hex>"}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key.
- **GeoLite2 databases.** Operators run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build`; the image bakes the MMDBs into `/opt/geolite2/`. Without them the resolver falls back to online `ipwho.is` and the admin login overview surfaces "unknown" for offline lookups.
- **GeoLite2 databases.** Two ways in. Building your own image: run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build` and the MMDBs bake into `/opt/geolite2/`. Running the published image: mount a directory of `.mmdb` files and point `GEOLITE2_DIR` at it — it is on the compose `environment:` whitelist and documented in `docs/self-hosting/geolite2.md`. Without either, the resolver falls back to the online provider and every login IP leaves the host.
- **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds.
## iOS handoff
+23 -2
View File
@@ -15,8 +15,10 @@
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
# data dir). The app container is stateless — GeoLite2 MMDBs are baked into
# the image — so a backup of the database is a backup of everything.
# data dir). The app container is stateless, so a backup of the database is
# a backup of everything. The optional GeoLite2 databases are either baked
# into the image at build time or mounted read-only from the host and
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
services:
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
# self-host deployments stay working with `docker compose up`.
@@ -96,6 +98,15 @@ services:
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
# Directory the offline GeoLite2 databases are read from. Default
# `/opt/geolite2` — the path the published image bakes its own copy
# into, if the build had a MaxMind licence key. Point this at a
# directory you mount yourself (see the `volumes:` block below) to
# bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed
# with `geoipupdate` on your own schedule. Listed here so an operator
# value in .env reaches the container (the `environment:` block is a
# whitelist). See docs/self-hosting/geolite2.md.
GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}"
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
@@ -219,6 +230,16 @@ services:
# Set "true" only if your own iOS build carries Apple's Critical Alerts
# entitlement; otherwise urgent alerts use the time-sensitive level.
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
# Bring your own GeoLite2 databases. The published image only carries
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
# offline tier is usually absent and every login IP goes to the online
# provider instead. Create a free MaxMind account, pull
# GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the
# directory read-only, and set GEOLITE2_DIR to the container path.
# Steps: docs/self-hosting/geolite2.md.
#
# volumes:
# - ./geolite2:/var/lib/geolite2:ro
depends_on:
db:
condition: service_healthy
+4 -1
View File
@@ -23,7 +23,10 @@ notes, and machine-readable specs that ship alongside the source:
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
— OIDC SSO login: IdP setup, the identity-pinning security model,
`OIDC_ONLY` consequences, and the break-glass runbook.
`OIDC_ONLY` consequences, and the break-glass runbook. Also
[`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your
own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending
login IPs to a third-party lookup.
- [`migration/`](./migration/) — release-by-release migration notes.
Read the entry for the version you're upgrading from.
- [`audit/`](./audit/) — per-release audit summaries archived for
+50 -9
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.33.0
version: 1.33.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -4477,10 +4477,10 @@ paths:
tags:
- Auth
summary: Read the calling user's AI-provider configuration
description: "Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true
when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy`
reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char
masked preview only."
description: "Returns the user's selected provider/model/baseUrl (plus the OpenAI-compatible gateway's own base URL and
model) and the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK,
self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility
off these two fields. Key material is reported as presence + a 4-char masked preview only."
responses:
"200":
description: Resolved AI-provider configuration.
@@ -12100,10 +12100,11 @@ components:
items:
$ref: "#/components/schemas/AppleHealthBatchEntry"
syncTrigger:
description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake)
so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere
else — it does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible:
pre-#66 clients omit it."
description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake).
Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt`
for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not
affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients
omit it."
type: string
enum:
- foreground
@@ -21942,6 +21943,7 @@ components:
- OPENAI
- ANTHROPIC
- LOCAL
- OPENAI_COMPATIBLE
- CHATGPT_OAUTH
- type: "null"
description: The user's selected provider, or null when none is set.
@@ -21984,6 +21986,21 @@ components:
- type: string
- type: "null"
description: "`...` + last 4 chars of the stored OpenAI key, or null."
compatBaseUrl:
anyOf:
- type: string
- type: "null"
description: "v1.33.1 (#470) — base URL of the OpenAI-compatible gateway (LiteLLM / OpenRouter / vLLM). Its own column:
the OpenAI provider stays pinned to api.openai.com and never reads it."
compatModel:
anyOf:
- type: string
- type: "null"
description: Model the gateway should route. Null falls back to `model`; with neither set the gateway entry does not
resolve.
hasCompatKey:
type: boolean
description: Whether a bearer is stored for the gateway. The bearer is optional — a gateway without auth needs none.
responseTimeoutSeconds:
anyOf:
- type: integer
@@ -22003,6 +22020,9 @@ components:
- hasLocalKey
- hasOpenaiKey
- openaiKeyPreview
- compatBaseUrl
- compatModel
- hasCompatKey
- responseTimeoutSeconds
additionalProperties: false
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
@@ -32612,6 +32632,27 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: When HealthKit last synced for this user; null when never (and always null on the PATCH echo).
lastSyncTrigger:
description: What the client declared triggered the most recent accepted batch. Null when the client sent no trigger —
an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo.
anyOf:
- type: string
enum:
- foreground
- background
- push
description: What triggered a HealthKit batch.
- type: "null"
lastBackgroundSyncAt:
description: When a batch last arrived without the app being opened (a `background` or `push` trigger). Null when that
has never happened. `lastSyncedAt` alone cannot separate a phone that delivers on its own from one that only
delivers while the app is open; this field is what does. Present on the GET read; omitted from the PATCH
echo.
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
syncHealth:
description: Present on the GET read; omitted from the PATCH echo.
$ref: "#/components/schemas/SyncHealth"
+58 -20
View File
@@ -9,14 +9,15 @@ Ollama endpoint can fall back from one to the other automatically.
## Provider matrix
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| ------------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | --------------------------------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `OPENAI_COMPATIBLE` | Your gateway's key, or none | none (you name it) | OpenAI-compatible URL you configure | Reaches whatever your gateway routes to |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
The four spellings above are the canonical `User.aiProvider` enum
The five spellings above are the canonical `User.aiProvider` enum
values. Anything else falls through to the admin-shared OpenAI key
(if configured) or the `NoProvider` stub that surfaces "No AI
provider configured" to the UI.
@@ -54,6 +55,11 @@ order. The default is encoded in
4. `local` — self-hosted Ollama / LM Studio / vLLM endpoint.
5. `admin-openai` — operator's shared key (last-ditch).
`openai-compatible` is a sixth chain entry you can add in the editor.
It is deliberately not in the default chain: it resolves only against a
base URL you saved, so a default entry would be dead weight for
everyone who never configures a gateway.
Entries the user has no credential for are silently skipped. The
runner walks the surviving entries in order; the first to return a
non-error completion wins. A 422 fires only when every entry in the
@@ -150,34 +156,59 @@ internal endpoint.
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).
### OpenAI-compatible gateways (LiteLLM, OpenRouter, …)
## OpenAI-compatible gateways (LiteLLM, OpenRouter, vLLM, …)
The **Local** provider is also the gateway path: any service that
speaks the OpenAI `/v1/chat/completions` wire plugs in the same way,
whether it runs on your host or not. The OPENAI provider deliberately
pins `api.openai.com` (an OpenAI key is never forwarded to a custom
host), so a gateway always goes through **Local**:
Since v1.33.1 a gateway has its own provider: **OpenAI-compatible
gateway**. Any service that speaks the OpenAI `/v1/chat/completions`
wire plugs in there, whether it runs on your network or not.
1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**.
1. In `/settings/ai`, set provider to **OpenAI-compatible gateway**.
2. Base URL is the gateway's OpenAI-compatible root and **ends with
`/v1`** — e.g. `https://litellm.example.com/v1` or
`https://openrouter.ai/api/v1`.
`/v1`** — e.g. `https://litellm.example.com/v1`,
`https://openrouter.ai/api/v1`, or `http://vllm.lan:8000/v1`.
3. API key is **optional** — set it when the gateway requires a
Bearer token (OpenRouter key, LiteLLM master key), leave it blank
otherwise. It is encrypted at rest like every other credential.
otherwise. It is encrypted at rest like every other credential,
in its own column.
4. Model is whatever the gateway routes — e.g.
`anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias.
Leave it blank and the model saved for your other providers is
used; with neither set the provider is skipped.
Then add **OpenAI-compatible gateway** to the fallback chain (it is
not in the default one) and put it where you want it tried.
**Why it is a separate provider and not a base-URL field on OPENAI.**
The OPENAI provider pins `api.openai.com`, and the gateway provider
reads three columns of its own: its base URL, its own key, its own
model. Neither can see the other's. A key you issued to OpenAI can
therefore never be sent to a host you typed into a different form,
and that property is structural rather than a comment — the OPENAI
arm has no code path that reads the gateway's base URL.
**SSRF guard.** The gateway's base URL passes the same floor as the
Local provider's: a public host always, a private one only when the
operator allowlisted it via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (documented
under Local endpoints above). LAN gateways are the normal case for LiteLLM and vLLM, so
the escape hatch matters here as much as it does for Ollama.
JSON surfaces send the standard
`response_format: { type: "json_object" }`; an endpoint that rejects
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
**The Local provider still works for this** and nothing about it
changed — it speaks the same wire and honours the same base URL. Pick
the gateway provider when the endpoint is a gateway (it says so in
the UI, it keeps a separate key, and it can sit in the chain
alongside Local), and pick Local for a model running on your own
machine.
The **admin** server-key path has a separate guard: an admin-set
custom base URL must additionally be allowed via
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The
user-level Local provider does not read that variable — it is
governed by the SSRF guard above.
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. Neither
the user-level Local provider nor the gateway provider reads that
variable — both are governed by the SSRF guard above.
## ChatGPT OAuth (Codex)
@@ -256,6 +287,13 @@ provider instead; their own entries always resolve first.
- **LOCAL** keeps everything on your network. Snapshots reach only
the endpoint you configured. With Ollama on the same host as
HealthLog, nothing leaves the host at all.
- **OPENAI_COMPATIBLE** reaches exactly the endpoint you configured,
and then wherever that endpoint routes. A gateway on your own
network keeps the data there; a hosted gateway forwards it to
whichever provider it fronts, under that provider's retention
policy. HealthLog cannot see past the gateway, so the honest
statement is the one above: this provider's privacy stance is your
gateway's.
- **CHATGPT_OAUTH** routes via OpenAI's Codex endpoint. Same caveat
as OpenAI; the difference is the billing axis (ChatGPT
subscription instead of API tokens).
+125
View File
@@ -0,0 +1,125 @@
# Offline geolocation (GeoLite2)
The admin login overview shows a coarse `City, CC` location and a carrier
next to every sign-in. Two tiers can produce that:
- **Online.** A third-party lookup, `https://ipwho.is` by default. Free,
keyless, and on by default — which also means every login IP address of
every account on the instance leaves the host.
- **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read
from a local directory. The ASN database is the authoritative source
for the carrier and the AS number. Note that the resolver is
online-first: having the databases in place is not by itself enough to
stop the outbound lookup, see "Turning the online lookup off entirely"
below.
The offline tier is optional and absent by default. The published image
only carries the databases when the image build had a MaxMind licence key
(`scripts/fetch-geolite2.sh`, run from the release workflow), which is a
build-time decision you do not control when you pull `:latest`. Bringing
your own copy is the way to get the offline tier on a self-host, and it
is the subject of this page.
## What you need
- A free MaxMind account. Sign up at
<https://www.maxmind.com/en/geolite2/signup>, then create a licence key
under **My Account → Manage License Keys**. GeoLite2 is free; the
account and key exist because MaxMind requires them for the download.
- `geoipupdate` (MaxMind's own downloader) or any other way to fetch the
two `.mmdb` files onto the host. Most distributions package
`geoipupdate`; there is also an official container image.
## Setup
1. Pick a directory on the host and fetch both databases into it. With
`geoipupdate`, `/etc/GeoIP.conf` looks like this:
```conf
AccountID <your-account-id>
LicenseKey <your-licence-key>
EditionIDs GeoLite2-City GeoLite2-ASN
DatabaseDirectory /srv/healthlog/geolite2
```
Then run `geoipupdate`. The directory should end up holding
`GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb`. The City database is the
one the readiness check looks for; the ASN database is what fills the
carrier and AS number.
2. Mount the directory into the app container read-only. In
`docker-compose.yml` the `app` service carries a commented `volumes:`
block for exactly this:
```yaml
volumes:
- /srv/healthlog/geolite2:/var/lib/geolite2:ro
```
3. Point the runtime at the container path:
```env
GEOLITE2_DIR="/var/lib/geolite2"
```
`GEOLITE2_DIR` is on the compose `environment:` whitelist, so a value
in your `.env` reaches the container. Variables that are not on that
whitelist never do, even when compose substitutes them elsewhere in
the file.
4. `docker compose up -d` and sign in once. **Admin → System status**
shows `Geo data: Offline (GeoLite2)` when the databases are being
read, and names the online provider when they are not.
## Turning the online lookup off entirely
Once the offline tier resolves, the third-party lookup is redundant. To
stop all IP egress:
```env
IP_GEO_LOOKUP_DISABLED="1"
```
The resolver then uses only the local databases and returns nothing when
they miss, rather than asking anyone. An instance handling other people's
health data is a reasonable place to make that trade.
## Keeping the databases current
MaxMind reissues GeoLite2 on its own schedule, and a stale city database
is quietly wrong rather than loudly broken — it keeps answering, with
last month's allocations. Run `geoipupdate` on a schedule (a weekly cron
or systemd timer is enough) against the same directory.
The reader loads each database once per worker process and holds it, so a
refreshed file on the host is picked up at the next container restart,
not immediately. A weekly `geoipupdate` followed by
`docker compose restart app` keeps the two in step.
## Licence and attribution
The databases are downloaded under your own MaxMind account, so their
terms are between you and MaxMind — read what your account agrees to at
<https://www.maxmind.com/en/geolite2/eula>. HealthLog's own attribution
("this product includes GeoLite2 data created by MaxMind") is already on
the `/about` page and covers an instance that uses them.
## Troubleshooting
**System status still says the online provider.** The readiness check
requires `GeoLite2-City.mmdb` to exist in `GEOLITE2_DIR` and no `.empty`
marker file beside it. Check the path from inside the container:
`docker compose exec app ls -l /var/lib/geolite2`. An empty listing means
the bind mount did not land; a `.empty` file means the image was built
without a licence key and you are still reading the baked-in directory
rather than your own.
**The variable has no effect.** Confirm it actually reached the process:
`docker compose exec app printenv GEOLITE2_DIR`. If it prints nothing,
the value is in `.env` but not on the `environment:` whitelist — compare
against `docker-compose.yml`.
**The carrier column is empty.** The carrier also comes from the online
provider's ISP field, so it is normally filled even without the ASN
database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no
`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty.
+13 -1
View File
@@ -1,6 +1,7 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
import {
LONG_HEADLINE_BRIEFING,
MOCK_SCORE_RINGS,
@@ -162,7 +163,16 @@ test.describe("phone-width density guard", () => {
await mockMoodInsights(page);
}
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
if (route === "/insights") {
// The one route here that had no readiness signal, while its two
// siblings below both have one. An overflow guard against a page
// that has not finished rendering passes for the same reason an
// empty page passes: there is nothing wide on it yet.
await expect(
page.locator('[data-slot="section-heading"]').first(),
).toBeVisible();
}
if (route === "/") {
// The guard must not pass vacuously — the Today hero and its
@@ -186,6 +196,8 @@ test.describe("phone-width density guard", () => {
).toBeVisible();
}
await settleBeforeMeasure(page, page.locator("main#main-content"));
// The document/body pair alone cannot see the app's sideways scroll:
// `AuthShell` scrolls inside `<main id="main-content">`, whose
// `overflow-y:auto` makes `overflow-x` compute to `auto`, so an
+5 -1
View File
@@ -1,6 +1,7 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
@@ -78,7 +79,10 @@ test.describe("mobile-viewport smoke", () => {
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Gate on the dashboard's own content, not on the network. Every read
// below is geometric and happens exactly once, so measuring a tree that
// is still mounting reports a layout nobody will ever see.
await settleBeforeMeasure(page, page.locator("main#main-content"));
// 1) No horizontal scroll
const dims = await page.evaluate(() => ({
+6 -3
View File
@@ -1,6 +1,7 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
/**
* Vertical over-scroll regression guard (the recurring class behind the
@@ -63,9 +64,11 @@ test.describe("settings + admin vertical over-scroll guard", () => {
test(`no over-scroll on ${route} (${vp.name})`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Let entry transitions / late queries settle before measuring.
await page.waitForTimeout(500);
// Was a fixed 500 ms sleep, which is the wrong shape twice over: too
// long when the page is ready and too short on a loaded runner, and
// it never says what it is waiting for. Gate on the element the
// measurement reads instead.
await settleBeforeMeasure(page, page.locator("#main-content"));
const dims = await page.evaluate(() => {
const main = document.getElementById("main-content");
+39
View File
@@ -0,0 +1,39 @@
import { expect, type Locator, type Page } from "@playwright/test";
/**
* Gate a one-shot DOM read on the element it is about to measure.
*
* The failure this exists to prevent: a spec navigates with
* `waitUntil: "networkidle"`, then reads the DOM in a single
* `page.evaluate` / `evaluateAll` that does not retry. An idle network says
* nothing about whether React has rendered, so the read can land on a tree
* that is still mounting and the assertion then blames the application for
* something the test raced — "language + dob fields must exist" when the
* fields were simply not there yet, or a measurement helper accused of
* measuring the wrong element when it was measuring the right one too early.
*
* Two such tests went red on consecutive CI runs during v1.33.0. Neither
* reproduces locally: a developer machine is fast enough that the render
* always wins the race, so the only place this shows up is a loaded runner,
* and `failOnFlakyTests` means every occurrence costs a whole red pipeline
* even when the retry passes.
*
* `sentinel` must be something the measurement actually depends on, not just
* any element on the page. Gating on a layout shell that renders before the
* data proves nothing.
*/
export async function settleBeforeMeasure(
page: Page,
sentinel: Locator,
options: { attachedOnly?: boolean } = {},
): Promise<void> {
if (options.attachedOnly) {
await expect(sentinel).toBeAttached();
} else {
await expect(sentinel).toBeVisible();
}
// Fonts and late CSS change geometry after the element is visible, and
// every measurement in this suite is geometric.
await page.waitForLoadState("networkidle").catch(() => {});
await page.evaluate(() => document.fonts?.ready);
}
+30 -8
View File
@@ -468,6 +468,8 @@
"moveDown": "Nach unten",
"layoutTileColumn": "Kachel",
"layoutChartColumn": "Chart",
"layoutNativeOnlyTitle": "In der mobilen App sichtbar",
"layoutNativeOnlyHint": "Diese Widgets zeichnet die mobile App, nicht diese Seite. Sie liegen im selben gespeicherten Layout — hier wird ihre Sichtbarkeit gesetzt.",
"dragHandle": "Zum Verschieben ziehen",
"dragHandleHint": "Pfeiltasten Hoch/Runter, oder Leertaste plus Pfeiltasten am fokussierten Griff.",
"emptyTitle": "Dein Dashboard ist leer",
@@ -4497,7 +4499,7 @@
},
"fullBackup": {
"title": "Voll-Backup",
"description": "Alle deine Daten in einer JSON-Datei — Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese und Workouts. Gleiches Format wie das wöchentliche Auto-Backup.",
"description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe und Workouts in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
"scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
"encryptToggle": "Mit einer Passphrase verschlüsseln",
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
@@ -4937,6 +4939,7 @@
"openai": "OpenAI (eigener Key)",
"anthropic": "Anthropic (Claude)",
"local": "Lokales Modell",
"openai-compatible": "OpenAI-kompatibles Gateway",
"admin-openai": "Admin-OpenAI"
},
"title": "Fallback-Kette",
@@ -4963,8 +4966,14 @@
"openai": "OpenAI (eigener API-Key)",
"anthropic": "Anthropic (Claude)",
"local": "Lokales Modell (OpenAI-kompatibel)",
"openai-compatible": "OpenAI-kompatibles Gateway",
"admin-openai": "Admin-OpenAI"
},
"compat": {
"description": "Ein vollwertiger Anbieter für jeden Endpunkt, der die OpenAI-API /v1/chat/completions spricht: LiteLLM, OpenRouter, vLLM oder ein anderes Gateway. Die Basis-URL endet auf /v1, der API-Key ist optional, und das Modell ist das, was dein Gateway weiterleitet. Dein OpenAI-Key erreicht diesen Endpunkt nie — dieser Anbieter hat einen eigenen.",
"keyLabel": "API-Key (optional)",
"modelHint": "Der Modellname, den dein Gateway erwartet. Bleibt das Feld leer, wird das für deine anderen Anbieter gespeicherte Modell verwendet; ist auch das leer, wird dieser Anbieter übersprungen."
},
"openai": {
"modelSelect": "Modell",
"modelOptionCustom": "Eigener Slug…",
@@ -5091,8 +5100,8 @@
"responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.",
"autoRead": {
"title": "Dokumente automatisch mit KI lesen",
"subLabel": "Neu hochgeladene Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.",
"honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden – ohne Bestätigung pro Dokument. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen – Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für die von dir hochgeladenen Dokumente akzeptierst.",
"subLabel": "Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.",
"honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden – ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen – Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.",
"confirm": "Ich verstehe – automatisches KI-Lesen aktivieren",
"cancel": "Abbrechen"
},
@@ -5290,7 +5299,6 @@
"minutesAgo": "vor {count} min",
"hoursAgo": "vor {count} Std.",
"daysAgo": "vor {count} d",
"ariaLabel": "Integrationsstatus",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen",
@@ -5834,7 +5842,20 @@
"backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.",
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
"importAction": "Einmaligen Import öffnen",
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen."
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.",
"delivery": {
"title": "Übermittlung",
"lastTriggerLabel": "Letzte Synchronisierung kam",
"lastBackgroundLabel": "Letzte Hintergrund-Synchronisierung",
"backgroundNever": "Nie — bisher kam jede Synchronisierung bei geöffneter App an",
"noSyncYet": "Es ist noch keine Synchronisierung angekommen, daher gibt es hier nichts zu berichten.",
"trigger": {
"foreground": "bei geöffneter App",
"background": "im Hintergrund",
"push": "durch einen Push-Weckruf",
"unknown": "Auslöser von der App nicht gemeldet"
}
}
},
"garminInfo": {
"title": "Garmin",
@@ -5949,7 +5970,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Geodaten",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}) — MAXMIND_LICENSE_KEY in CI setzen",
"offlineGeoFallback": "Online ({host}) — GeoLite2-Datenbanken einbinden und GEOLITE2_DIR setzen",
"apiGlobal": "API-Endpunkte und API-Tokens erlauben.",
"umamiTitle": "Umami",
"umamiDescription": "Web-Analyse mit Umami",
@@ -6750,7 +6771,7 @@
"reminderCheckOverdueTitle": "Einnahme überfällig: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.",
"offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen",
"offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer.",
"offlineGeoUnavailableBody": "HealthLog löst Login-Standorte über die Online-Abfrage bei {host} auf, damit verlässt jede Login-IP diesen Server. Um sie stattdessen lokal aufzulösen, lade die Datenbanken GeoLite2-City und GeoLite2-ASN mit einem kostenlosen MaxMind-Konto herunter, binde das Verzeichnis in den Container ein und setze GEOLITE2_DIR darauf. Anleitung: {docsUrl}",
"tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}",
"tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md."
},
@@ -8602,7 +8623,8 @@
"uploading": "Wird hochgeladen…",
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
"processing": "Wird verarbeitet…",
"ready": "Bereit"
"ready": "Bereit",
"deleteKey": "Entf"
},
"error": {
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
+30 -8
View File
@@ -468,6 +468,8 @@
"moveDown": "Move down",
"layoutTileColumn": "Tile",
"layoutChartColumn": "Chart",
"layoutNativeOnlyTitle": "Shown in the mobile app",
"layoutNativeOnlyHint": "The mobile app draws these widgets; this page does not. They are stored in the same layout, so this is where their visibility is set.",
"dragHandle": "Drag to reorder",
"dragHandleHint": "Drag the handle to reorder this widget. You can also use the Up and Down arrow buttons, or focus the handle and press Space and the arrow keys.",
"emptyTitle": "Your dashboard is empty",
@@ -4497,7 +4499,7 @@
},
"fullBackup": {
"title": "Full backup",
"description": "Every record you own in one JSON file — measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, and workouts. Same shape as the weekly auto-backup.",
"description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients and workouts in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
"scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.",
"encryptToggle": "Encrypt with a passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
@@ -4937,6 +4939,7 @@
"openai": "OpenAI (your key)",
"anthropic": "Anthropic (Claude)",
"local": "Local model",
"openai-compatible": "OpenAI-compatible gateway",
"admin-openai": "Admin OpenAI"
},
"title": "Fallback chain",
@@ -4963,8 +4966,14 @@
"openai": "OpenAI (your API key)",
"anthropic": "Anthropic (Claude)",
"local": "Local model (OpenAI-compatible)",
"openai-compatible": "OpenAI-compatible gateway",
"admin-openai": "Admin-provided OpenAI"
},
"compat": {
"description": "A first-class provider for any endpoint that speaks the OpenAI /v1/chat/completions API: LiteLLM, OpenRouter, vLLM or another gateway. The base URL ends with /v1, the API key is optional, and the model is whatever your gateway routes. Your OpenAI key never reaches this endpoint — this provider has its own.",
"keyLabel": "API key (optional)",
"modelHint": "The model name your gateway expects. Left blank, the model saved for your other providers is used; with neither set, this provider is skipped."
},
"openai": {
"modelSelect": "Model",
"modelOptionCustom": "Custom slug…",
@@ -5091,8 +5100,8 @@
"responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.",
"autoRead": {
"title": "Read documents automatically with AI",
"subLabel": "Newly uploaded documents are read and indexed by your configured AI provider without a per-document tap.",
"honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for the documents you upload.",
"subLabel": "Documents are read and indexed by your configured AI provider without a per-document tap.",
"honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.",
"confirm": "I understand — turn on automatic AI reading",
"cancel": "Cancel"
},
@@ -5290,7 +5299,6 @@
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
"ariaLabel": "Integration status",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed",
@@ -5834,7 +5842,20 @@
"backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.",
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import",
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions."
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
"delivery": {
"title": "Delivery",
"lastTriggerLabel": "Latest sync arrived",
"lastBackgroundLabel": "Last background sync",
"backgroundNever": "Never — every sync so far arrived with the app open",
"noSyncYet": "No sync has arrived yet, so there is nothing to report here.",
"trigger": {
"foreground": "with the app open",
"background": "in the background",
"push": "from a push wake",
"unknown": "trigger not reported by the app"
}
}
},
"garminInfo": {
"title": "Garmin",
@@ -5949,7 +5970,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Geo data",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}) — set MAXMIND_LICENSE_KEY in CI",
"offlineGeoFallback": "Online ({host}) — mount GeoLite2 databases and set GEOLITE2_DIR",
"apiGlobal": "Allow API endpoints and API tokens.",
"umamiTitle": "Umami",
"umamiDescription": "Web analytics with Umami",
@@ -6750,7 +6771,7 @@
"reminderCheckOverdueTitle": "Dose overdue: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.",
"offlineGeoUnavailableTitle": "Offline geolocation databases not loaded",
"offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty.",
"offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online lookup at {host}, so every login IP address leaves this server. To resolve them locally instead, download the GeoLite2-City and GeoLite2-ASN databases with a free MaxMind account, mount the directory into the container, and point GEOLITE2_DIR at it. Steps: {docsUrl}",
"tlsPinChangedTitle": "TLS leaf certificate changed: {host}",
"tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md."
},
@@ -8602,7 +8623,8 @@
"uploading": "Uploading…",
"dismissFailed": "Dismiss failed upload",
"processing": "Processing…",
"ready": "Ready"
"ready": "Ready",
"deleteKey": "Del"
},
"error": {
"fileTooLarge": "This file is larger than the {maxSize} limit.",
+30 -8
View File
@@ -468,6 +468,8 @@
"moveDown": "Bajar",
"layoutTileColumn": "Tarjeta",
"layoutChartColumn": "Gráfico",
"layoutNativeOnlyTitle": "Visible en la app móvil",
"layoutNativeOnlyHint": "Estos widgets los dibuja la app móvil, no esta página. Se guardan en el mismo diseño, así que aquí es donde se define su visibilidad.",
"dragHandle": "Arrastra para reordenar",
"dragHandleHint": "Arrastra el manejador para reordenar este widget. También puedes usar los botones de flecha Arriba y Abajo, o enfocar el manejador y pulsar Espacio más las flechas.",
"emptyTitle": "Tu panel está vacío",
@@ -4497,7 +4499,7 @@
},
"fullBackup": {
"title": "Copia completa",
"description": "Todos tus datos en un único archivo JSON: mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares y entrenamientos. Mismo formato que la copia automática semanal.",
"description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes y entrenamientos en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
"scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
"encryptToggle": "Cifrar con una contraseña",
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
@@ -4937,6 +4939,7 @@
"openai": "OpenAI (tu clave)",
"anthropic": "Anthropic (Claude)",
"local": "Modelo local",
"openai-compatible": "Pasarela compatible con OpenAI",
"admin-openai": "OpenAI de administrador"
},
"title": "Cadena de respaldo",
@@ -4963,8 +4966,14 @@
"openai": "OpenAI (tu clave de API)",
"anthropic": "Anthropic (Claude)",
"local": "Modelo local (compatible con OpenAI)",
"openai-compatible": "Pasarela compatible con OpenAI",
"admin-openai": "OpenAI proporcionado por el administrador"
},
"compat": {
"description": "Un proveedor de pleno derecho para cualquier endpoint que hable la API /v1/chat/completions de OpenAI: LiteLLM, OpenRouter, vLLM u otra pasarela. La URL base termina en /v1, la clave de API es opcional y el modelo es el que enrute tu pasarela. Tu clave de OpenAI nunca llega a este endpoint: este proveedor tiene la suya.",
"keyLabel": "Clave de API (opcional)",
"modelHint": "El nombre de modelo que espera tu pasarela. Si lo dejas vacío, se usa el modelo guardado para tus otros proveedores; si tampoco hay uno, este proveedor se omite."
},
"openai": {
"modelSelect": "Modelo",
"modelOptionCustom": "Slug personalizado…",
@@ -5091,8 +5100,8 @@
"responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.",
"autoRead": {
"title": "Leer documentos automáticamente con IA",
"subLabel": "Los documentos recién subidos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.",
"honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para los documentos que subes.",
"subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.",
"honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.",
"confirm": "Lo entiendo: activar la lectura automática con IA",
"cancel": "Cancelar"
},
@@ -5290,7 +5299,6 @@
"minutesAgo": "hace {count} min",
"hoursAgo": "hace {count} h",
"daysAgo": "hace {count} d",
"ariaLabel": "Estado de la integración",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida",
@@ -5834,7 +5842,20 @@
"backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.",
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual",
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health."
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
"delivery": {
"title": "Entrega",
"lastTriggerLabel": "La última sincronización llegó",
"lastBackgroundLabel": "Última sincronización en segundo plano",
"backgroundNever": "Nunca — hasta ahora todas las sincronizaciones llegaron con la app abierta",
"noSyncYet": "Aún no ha llegado ninguna sincronización, así que no hay nada que informar aquí.",
"trigger": {
"foreground": "con la app abierta",
"background": "en segundo plano",
"push": "desde un aviso push",
"unknown": "la app no informó del desencadenante"
}
}
},
"garminInfo": {
"title": "Garmin",
@@ -5949,7 +5970,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Datos geográficos",
"offlineGeoEnabled": "Sin conexión (GeoLite2)",
"offlineGeoFallback": "En línea ({host}) — configura MAXMIND_LICENSE_KEY en CI",
"offlineGeoFallback": "En línea ({host}) — monta las bases GeoLite2 y define GEOLITE2_DIR",
"apiGlobal": "Permitir endpoints de API y tokens de API.",
"umamiTitle": "Umami",
"umamiDescription": "Analítica web con Umami",
@@ -6750,7 +6771,7 @@
"reminderCheckOverdueTitle": "Dosis atrasada: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.",
"offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas",
"offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía.",
"offlineGeoUnavailableBody": "HealthLog resuelve las ubicaciones de inicio de sesión mediante la consulta en línea a {host}, por lo que cada IP de inicio de sesión sale de este servidor. Para resolverlas localmente, descarga las bases GeoLite2-City y GeoLite2-ASN con una cuenta gratuita de MaxMind, monta el directorio en el contenedor y apunta GEOLITE2_DIR a él. Pasos: {docsUrl}",
"tlsPinChangedTitle": "Certificado de hoja TLS cambiado: {host}",
"tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md."
},
@@ -8602,7 +8623,8 @@
"uploading": "Subiendo…",
"dismissFailed": "Descartar subida fallida",
"processing": "Procesando…",
"ready": "Listo"
"ready": "Listo",
"deleteKey": "Supr"
},
"error": {
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
+30 -8
View File
@@ -468,6 +468,8 @@
"moveDown": "Descendre",
"layoutTileColumn": "Tuile",
"layoutChartColumn": "Graphique",
"layoutNativeOnlyTitle": "Affiché dans l'application mobile",
"layoutNativeOnlyHint": "Ces widgets sont dessinés par l'application mobile, pas par cette page. Ils sont enregistrés dans la même disposition : c'est ici que leur visibilité se règle.",
"dragHandle": "Glisser pour réordonner",
"dragHandleHint": "Glisse la poignée pour réordonner ce widget. Tu peux aussi utiliser les flèches Haut et Bas, ou cibler la poignée puis appuyer sur Espace plus les flèches.",
"emptyTitle": "Ton tableau de bord est vide",
@@ -4497,7 +4499,7 @@
},
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Toutes vos données dans un seul fichier JSON : mesures, médicaments, humeur, cycle, analyses de laboratoire, épisodes de maladie, allergies, antécédents familiaux et entraînements. Même format que la sauvegarde automatique hebdomadaire.",
"description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments et séances dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
"scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
"encryptToggle": "Chiffrer avec une phrase secrète",
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
@@ -4937,6 +4939,7 @@
"openai": "OpenAI (votre clé)",
"anthropic": "Anthropic (Claude)",
"local": "Modèle local",
"openai-compatible": "Passerelle compatible OpenAI",
"admin-openai": "OpenAI administrateur"
},
"title": "Chaîne de secours",
@@ -4963,8 +4966,14 @@
"openai": "OpenAI (votre clé API)",
"anthropic": "Anthropic (Claude)",
"local": "Modèle local (compatible OpenAI)",
"openai-compatible": "Passerelle compatible OpenAI",
"admin-openai": "OpenAI fourni par l’administrateur"
},
"compat": {
"description": "Un fournisseur à part entière pour tout point de terminaison qui parle l'API /v1/chat/completions d'OpenAI : LiteLLM, OpenRouter, vLLM ou une autre passerelle. L'URL de base se termine par /v1, la clé API est facultative et le modèle est celui que votre passerelle achemine. Votre clé OpenAI n'atteint jamais ce point de terminaison — ce fournisseur a la sienne.",
"keyLabel": "Clé API (facultative)",
"modelHint": "Le nom de modèle attendu par votre passerelle. Laissé vide, le modèle enregistré pour vos autres fournisseurs est utilisé ; si aucun n'est défini, ce fournisseur est ignoré."
},
"openai": {
"modelSelect": "Modèle",
"modelOptionCustom": "Slug personnalisé…",
@@ -5091,8 +5100,8 @@
"responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.",
"autoRead": {
"title": "Lire les documents automatiquement avec l'IA",
"subLabel": "Les documents nouvellement téléversés sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.",
"honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour les documents que vous téléversez.",
"subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.",
"honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.",
"confirm": "Je comprends — activer la lecture automatique par l'IA",
"cancel": "Annuler"
},
@@ -5290,7 +5299,6 @@
"minutesAgo": "il y a {count} min",
"hoursAgo": "il y a {count} h",
"daysAgo": "il y a {count} j",
"ariaLabel": "Statut de l’intégration",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion",
@@ -5834,7 +5842,20 @@
"backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.",
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel",
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health."
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
"delivery": {
"title": "Livraison",
"lastTriggerLabel": "La dernière synchronisation est arrivée",
"lastBackgroundLabel": "Dernière synchronisation en arrière-plan",
"backgroundNever": "Jamais — jusqu'ici chaque synchronisation est arrivée avec l'app ouverte",
"noSyncYet": "Aucune synchronisation n'est encore arrivée, il n'y a donc rien à signaler ici.",
"trigger": {
"foreground": "avec l'app ouverte",
"background": "en arrière-plan",
"push": "depuis un réveil push",
"unknown": "déclencheur non signalé par l'app"
}
}
},
"garminInfo": {
"title": "Garmin",
@@ -5949,7 +5970,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Données géographiques",
"offlineGeoEnabled": "Hors ligne (GeoLite2)",
"offlineGeoFallback": "En ligne ({host}) — définissez MAXMIND_LICENSE_KEY dans la CI",
"offlineGeoFallback": "En ligne ({host}) — montez les bases GeoLite2 et définissez GEOLITE2_DIR",
"apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.",
"umamiTitle": "Umami",
"umamiDescription": "Analyse web avec Umami",
@@ -6750,7 +6771,7 @@
"reminderCheckOverdueTitle": "Prise en retard : {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.",
"offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées",
"offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide.",
"offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via la requête en ligne vers {host}, donc chaque adresse IP de connexion quitte ce serveur. Pour les résoudre localement, téléchargez les bases GeoLite2-City et GeoLite2-ASN avec un compte MaxMind gratuit, montez le répertoire dans le conteneur et faites-y pointer GEOLITE2_DIR. Étapes : {docsUrl}",
"tlsPinChangedTitle": "Certificat feuille TLS modifié : {host}",
"tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md."
},
@@ -8602,7 +8623,8 @@
"uploading": "Téléversement…",
"dismissFailed": "Ignorer le téléversement échoué",
"processing": "Traitement en cours…",
"ready": "Prêt"
"ready": "Prêt",
"deleteKey": "Suppr"
},
"error": {
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
+30 -8
View File
@@ -468,6 +468,8 @@
"moveDown": "Sposta giù",
"layoutTileColumn": "Riquadro",
"layoutChartColumn": "Grafico",
"layoutNativeOnlyTitle": "Visibile nell'app mobile",
"layoutNativeOnlyHint": "Questi widget li disegna l'app mobile, non questa pagina. Sono salvati nello stesso layout, quindi è qui che se ne imposta la visibilità.",
"dragHandle": "Trascina per riordinare",
"dragHandleHint": "Trascina la maniglia per riordinare questo widget. Puoi anche usare i pulsanti freccia Su e Giù, oppure mettere a fuoco la maniglia e premere Spazio e i tasti freccia.",
"emptyTitle": "La tua dashboard è vuota",
@@ -4497,7 +4499,7 @@
},
"fullBackup": {
"title": "Backup completo",
"description": "Tutti i tuoi dati in un unico file JSON: misurazioni, farmaci, umore, ciclo, esami di laboratorio, episodi di malattia, allergie, anamnesi familiare e allenamenti. Stesso formato del backup automatico settimanale.",
"description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti e gli allenamenti in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
"scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
"encryptToggle": "Cifra con una passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
@@ -4937,6 +4939,7 @@
"openai": "OpenAI (la tua chiave)",
"anthropic": "Anthropic (Claude)",
"local": "Modello locale",
"openai-compatible": "Gateway compatibile con OpenAI",
"admin-openai": "OpenAI amministratore"
},
"title": "Catena di fallback",
@@ -4963,8 +4966,14 @@
"openai": "OpenAI (la tua chiave API)",
"anthropic": "Anthropic (Claude)",
"local": "Modello locale (compatibile con OpenAI)",
"openai-compatible": "Gateway compatibile con OpenAI",
"admin-openai": "OpenAI fornito dall’amministratore"
},
"compat": {
"description": "Un provider a pieno titolo per qualsiasi endpoint che parli l'API /v1/chat/completions di OpenAI: LiteLLM, OpenRouter, vLLM o un altro gateway. L'URL di base termina con /v1, la chiave API è facoltativa e il modello è quello instradato dal gateway. La tua chiave OpenAI non raggiunge mai questo endpoint: questo provider ha la propria.",
"keyLabel": "Chiave API (facoltativa)",
"modelHint": "Il nome del modello che il gateway si aspetta. Se lasci il campo vuoto viene usato il modello salvato per gli altri provider; se non è impostato nessuno dei due, questo provider viene saltato."
},
"openai": {
"modelSelect": "Modello",
"modelOptionCustom": "Slug personalizzato…",
@@ -5091,8 +5100,8 @@
"responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.",
"autoRead": {
"title": "Leggi i documenti automaticamente con l'IA",
"subLabel": "I documenti appena caricati vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.",
"honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i documenti che carichi.",
"subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.",
"honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.",
"confirm": "Ho capito: attiva la lettura automatica con l'IA",
"cancel": "Annulla"
},
@@ -5290,7 +5299,6 @@
"minutesAgo": "{count} min fa",
"hoursAgo": "{count} h fa",
"daysAgo": "{count} g fa",
"ariaLabel": "Stato dell’integrazione",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita",
@@ -5834,7 +5842,20 @@
"backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.",
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum",
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health."
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
"delivery": {
"title": "Consegna",
"lastTriggerLabel": "L'ultima sincronizzazione è arrivata",
"lastBackgroundLabel": "Ultima sincronizzazione in background",
"backgroundNever": "Mai — finora ogni sincronizzazione è arrivata con l'app aperta",
"noSyncYet": "Non è ancora arrivata alcuna sincronizzazione, quindi qui non c'è nulla da riferire.",
"trigger": {
"foreground": "con l'app aperta",
"background": "in background",
"push": "da una sveglia push",
"unknown": "trigger non segnalato dall'app"
}
}
},
"garminInfo": {
"title": "Garmin",
@@ -5949,7 +5970,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Dati geografici",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}) — imposta MAXMIND_LICENSE_KEY in CI",
"offlineGeoFallback": "Online ({host}) — monta i database GeoLite2 e imposta GEOLITE2_DIR",
"apiGlobal": "Consentire endpoint API e token API.",
"umamiTitle": "Umami",
"umamiDescription": "Analisi web con Umami",
@@ -6750,7 +6771,7 @@
"reminderCheckOverdueTitle": "Dose in ritardo: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.",
"offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati",
"offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota.",
"offlineGeoUnavailableBody": "HealthLog risolve le posizioni di accesso tramite la ricerca online su {host}, quindi ogni IP di accesso lascia questo server. Per risolverle localmente, scarica i database GeoLite2-City e GeoLite2-ASN con un account MaxMind gratuito, monta la directory nel container e fai puntare GEOLITE2_DIR ad essa. Passaggi: {docsUrl}",
"tlsPinChangedTitle": "Certificato foglia TLS modificato: {host}",
"tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md."
},
@@ -8602,7 +8623,8 @@
"uploading": "Caricamento…",
"dismissFailed": "Ignora caricamento non riuscito",
"processing": "Elaborazione…",
"ready": "Pronto"
"ready": "Pronto",
"deleteKey": "Canc"
},
"error": {
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
+30 -8
View File
@@ -468,6 +468,8 @@
"moveDown": "W dół",
"layoutTileColumn": "Kafelek",
"layoutChartColumn": "Wykres",
"layoutNativeOnlyTitle": "Widoczne w aplikacji mobilnej",
"layoutNativeOnlyHint": "Te widżety rysuje aplikacja mobilna, nie ta strona. Są zapisane w tym samym układzie, więc to tutaj ustawia się ich widoczność.",
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"dragHandleHint": "Strzałki Góra/Dół, lub Spacja plus strzałki na zaznaczonym uchwycie.",
"emptyTitle": "Twój panel jest pusty",
@@ -4497,7 +4499,7 @@
},
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Wszystkie Twoje dane w jednym pliku JSON — pomiary, leki, nastrój, cykl, wyniki badań laboratoryjnych, epizody chorobowe, alergie, wywiad rodzinny i treningi. Taki sam format jak cotygodniowa automatyczna kopia.",
"description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze i treningi w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
"scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
"encryptToggle": "Zaszyfruj hasłem-frazą",
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
@@ -4937,6 +4939,7 @@
"openai": "OpenAI (Twój klucz)",
"anthropic": "Anthropic (Claude)",
"local": "Model lokalny",
"openai-compatible": "Brama zgodna z OpenAI",
"admin-openai": "OpenAI administratora"
},
"title": "Łańcuch zapasowy",
@@ -4963,8 +4966,14 @@
"openai": "OpenAI (Twój klucz API)",
"anthropic": "Anthropic (Claude)",
"local": "Model lokalny (zgodny z OpenAI)",
"openai-compatible": "Brama zgodna z OpenAI",
"admin-openai": "OpenAI udostępniony przez administratora"
},
"compat": {
"description": "Pełnoprawny dostawca dla każdego punktu końcowego mówiącego API /v1/chat/completions OpenAI: LiteLLM, OpenRouter, vLLM lub inna brama. Adres bazowy kończy się na /v1, klucz API jest opcjonalny, a model to ten, który kieruje twoja brama. Twój klucz OpenAI nigdy nie trafia do tego punktu końcowego — ten dostawca ma własny.",
"keyLabel": "Klucz API (opcjonalny)",
"modelHint": "Nazwa modelu, której oczekuje twoja brama. Pozostawione puste, użyty zostanie model zapisany dla pozostałych dostawców; gdy nie ustawiono żadnego, ten dostawca jest pomijany."
},
"openai": {
"modelSelect": "Model",
"modelOptionCustom": "Niestandardowy slug…",
@@ -5091,8 +5100,8 @@
"responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.",
"autoRead": {
"title": "Automatyczne czytanie dokumentów przez AI",
"subLabel": "Nowo przesłane dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.",
"honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla przesyłanych dokumentów.",
"subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.",
"honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane — do 200 przy każdym włączeniu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.",
"confirm": "Rozumiem — włącz automatyczne czytanie przez AI",
"cancel": "Anuluj"
},
@@ -5290,7 +5299,6 @@
"minutesAgo": "{count} min temu",
"hoursAgo": "{count} godz. temu",
"daysAgo": "{count} dni temu",
"ariaLabel": "Status integracji",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane",
@@ -5834,7 +5842,20 @@
"backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.",
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import",
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health."
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
"delivery": {
"title": "Dostarczanie",
"lastTriggerLabel": "Ostatnia synchronizacja dotarła",
"lastBackgroundLabel": "Ostatnia synchronizacja w tle",
"backgroundNever": "Nigdy — jak dotąd każda synchronizacja dotarła przy otwartej aplikacji",
"noSyncYet": "Żadna synchronizacja jeszcze nie dotarła, więc nie ma tu nic do zgłoszenia.",
"trigger": {
"foreground": "przy otwartej aplikacji",
"background": "w tle",
"push": "z wybudzenia push",
"unknown": "aplikacja nie zgłosiła wyzwalacza"
}
}
},
"garminInfo": {
"title": "Garmin",
@@ -5949,7 +5970,7 @@
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Dane geograficzne",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}) — ustaw MAXMIND_LICENSE_KEY w CI",
"offlineGeoFallback": "Online ({host}) — podłącz bazy GeoLite2 i ustaw GEOLITE2_DIR",
"apiGlobal": "Zezwól na punkty końcowe API i tokeny API.",
"umamiTitle": "Umami",
"umamiDescription": "Analityka internetowa z Umami",
@@ -6750,7 +6771,7 @@
"reminderCheckOverdueTitle": "Dawka opóźniona: {medication}",
"reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.",
"offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane",
"offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta.",
"offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez zapytanie online do {host}, więc każdy adres IP logowania opuszcza ten serwer. Aby rozwiązywać je lokalnie, pobierz bazy GeoLite2-City i GeoLite2-ASN na bezpłatnym koncie MaxMind, podłącz katalog do kontenera i ustaw na niego GEOLITE2_DIR. Kroki: {docsUrl}",
"tlsPinChangedTitle": "Zmieniono certyfikat liścia TLS: {host}",
"tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md."
},
@@ -8602,7 +8623,8 @@
"uploading": "Przesyłanie…",
"dismissFailed": "Odrzuć nieudane przesyłanie",
"processing": "Przetwarzanie…",
"ready": "Gotowe"
"ready": "Gotowe",
"deleteKey": "Del"
},
"error": {
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.33.0",
"version": "1.33.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,17 @@
-- The OpenAI-compatible gateway provider (#470): a user-configured endpoint
-- that speaks `/v1/chat/completions` — LiteLLM, OpenRouter, vLLM, or anything
-- else on that wire — with its own base URL, its own optional bearer, and its
-- own model.
--
-- Three dedicated columns instead of reusing `ai_base_url` / the OpenAI key.
-- The OpenAI arm pins `api.openai.com` on purpose, so it must have no column
-- in common with a provider whose host the user chooses; and the gateway must
-- never receive the key the user issued for OpenAI. Separating the storage
-- makes both properties structural rather than a comment someone can delete.
--
-- All three are nullable and additive: an install that never configures the
-- provider carries three NULLs and resolves exactly as before.
ALTER TABLE "users" ADD COLUMN "ai_compat_base_url" TEXT;
ALTER TABLE "users" ADD COLUMN "ai_compat_key_encrypted" TEXT;
ALTER TABLE "users" ADD COLUMN "ai_compat_model" TEXT;
@@ -0,0 +1,38 @@
-- The integration ledger stops erasing itself, and the HealthKit trigger gets
-- somewhere to live.
--
-- 1. `failing_leg` → `failing_legs`
--
-- `failing_leg` was a single last-writer-wins slot. Withings runs four legs on
-- four crons and WHOOP four resources on four more, so two legs failing in
-- overlapping windows overwrote each other: the row remembered whichever failed
-- last, and a success from that leg then cleared an error the other one was
-- still causing. The slot becomes a set. A failure adds its leg, a success
-- removes its own, and the row's error state, per-kind buckets and streak
-- anchor clear only when the set is empty.
--
-- The array is ordered oldest-failure-first, so the last element is the leg that
-- owns whatever message `last_error` holds.
--
-- 2. `users`: the HealthKit sync trigger, persisted
--
-- The batch route has accepted an optional `syncTrigger` since v1.32.8 and put
-- it on the wide event, where no human reads it. Persisting it — plus the
-- instant of the most recent background-triggered batch — is what lets the
-- Apple Health card answer whether the phone is delivering on its own or only
-- when the app is open. Both nullable and additive.
ALTER TABLE "integration_statuses" ADD COLUMN "failing_legs" JSONB;
-- Carry every attributed leg forward as a one-element set. Rows whose
-- `failing_leg` is NULL become NULL too, not an empty array: NULL and `[]` both
-- read as "failing, but unattributed", and leaving them NULL keeps the backfill
-- to the rows that actually carry information.
UPDATE "integration_statuses"
SET "failing_legs" = jsonb_build_array("failing_leg")
WHERE "failing_leg" IS NOT NULL;
ALTER TABLE "integration_statuses" DROP COLUMN "failing_leg";
ALTER TABLE "users" ADD COLUMN "healthkit_last_sync_trigger" TEXT;
ALTER TABLE "users" ADD COLUMN "healthkit_last_background_sync_at" TIMESTAMP(3);
+41 -7
View File
@@ -311,6 +311,18 @@ model User {
// before storage.
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
// v1.33.1 (#470) — the OpenAI-compatible gateway provider (LiteLLM,
// OpenRouter, vLLM, any endpoint speaking `/v1/chat/completions`).
// Three DEDICATED columns rather than a reuse of `aiBaseUrl` /
// `aiOpenaiKeyEncrypted`: the OpenAI arm pins `api.openai.com` and must
// never be able to read a user-supplied host, and the gateway credential
// must never be the OpenAI key. Separate columns make that structural
// instead of a comment. The base URL passes the same SSRF floor as the
// Local provider (`isPublicUrl` unless the operator allowlisted the host).
aiCompatBaseUrl String? @map("ai_compat_base_url")
aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted")
aiCompatModel String? @map("ai_compat_model")
// v1.4.16 phase B5b — ordered fallback chain for multi-provider AI
// redundancy. Each entry is `{ providerType, priority, enabled }`,
// sorted by ascending priority at runtime. Credentials live in the
@@ -403,8 +415,20 @@ model User {
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
healthKitConfigJson Json? @map("healthkit_config_json")
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
// What triggered the most recent accepted HealthKit batch — "foreground",
// "background" or "push", exactly as the client declared it on the write.
// Null when the client sent no trigger (an older build, or a batch that
// predates the field).
//
// `healthKitLastBackgroundSyncAt` dates the most recent batch that arrived
// WITHOUT the app being opened. It is the one instrument that tells "the
// phone is delivering on its own" apart from "every arrival happened while
// the app was on screen", which is otherwise indistinguishable from the
// outside — `healthKitLastSyncedAt` alone looks identical either way.
healthKitLastSyncTrigger String? @map("healthkit_last_sync_trigger")
healthKitLastBackgroundSyncAt DateTime? @map("healthkit_last_background_sync_at")
// Display name (optional override of username for greetings).
displayName String? @map("display_name")
@@ -4163,11 +4187,21 @@ model IntegrationStatus {
// blip apart from a dead pipe. Cleared by a success that is entitled to
// clear it, by a reconnect, a disconnect, and a resume.
failingSinceAt DateTime? @map("failing_since_at")
// Which sync leg owns the error currently on the row. `null` = the provider
// has a single leg, or the failure predates leg attribution. A success from
// a DIFFERENT leg may not clear an error this one did not cause — see
// `recordSyncSuccess`.
failingLeg String? @map("failing_leg")
// Every sync leg currently known to be failing, oldest failure first, as a
// JSON string array. A single slot could only ever remember one leg, so two
// legs of the same provider failing in overlapping windows erased each other
// and the row forgot one of them. A failure adds its leg; a success removes
// its own; the error state, the buckets and `failingSinceAt` clear only when
// the array is empty — see `recordSyncSuccess`.
//
// The LAST element is the leg that failed most recently and therefore owns
// whatever message `lastError` holds. When that leg recovers while others are
// still failing, `lastError` is cleared rather than left describing a leg that
// is now healthy.
//
// An empty array means "failing, but unattributed" (a single-leg provider, or
// a failure recorded without a leg), under which any success clears the row.
failingLegs Json? @map("failing_legs")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.33.0";
/* @sw-version-fallback */ "v1.33.1";
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/*`
+42
View File
@@ -289,4 +289,46 @@ describe("environment inventory", () => {
expect(manifestNames).toContain(variableName);
}
});
it("forwards GEOLITE2_DIR through the compose whitelist and documents it", () => {
// issue #659 — `src/lib/geo.ts` has always read this variable, but it
// was absent from the compose `environment:` block, and a variable
// that is not on that whitelist never reaches the container even when
// the operator's `.env` sets it. The override existed in code and was
// unreachable from the documented path.
const repositoryRoot = join(__dirname, "../..");
const compose = readFileSync(
join(repositoryRoot, "docker-compose.yml"),
"utf8",
);
const manifest = JSON.parse(
readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"),
) as { groups: Array<{ variables: Array<{ name: string }> }> };
// The compose line must SUBSTITUTE the operator's own value — a
// hardcoded literal would list the key and still ignore `.env`.
expect(compose).toMatch(
/^\s*GEOLITE2_DIR: "\$\{GEOLITE2_DIR(:-[^}]*)?\}"$/m,
);
// ...and the container needs a place to read the files from.
expect(compose).toMatch(/geolite2:ro$/m);
for (const exampleFile of [".env.example", ".env.production.example"]) {
const example = readFileSync(join(repositoryRoot, exampleFile), "utf8");
expect(example, `${exampleFile} does not document GEOLITE2_DIR`).toMatch(
/^# GEOLITE2_DIR=/m,
);
}
expect(
manifest.groups.flatMap((group) =>
group.variables.map((variable) => variable.name),
),
).toContain("GEOLITE2_DIR");
// The runbook the admin notification and both examples point at.
expect(() =>
readFileSync(join(repositoryRoot, "docs/self-hosting/geolite2.md")),
).not.toThrow();
});
});
+11
View File
@@ -132,6 +132,17 @@
}
]
},
{
"name": "Offline geolocation",
"description": "Optional offline GeoLite2 tier for the admin login overview. Unset, the runtime reads /opt/geolite2 (the path the image bakes its own copy into) and falls back to the online provider when the databases are absent.",
"required": false,
"variables": [
{
"name": "GEOLITE2_DIR",
"purpose": "Directory holding GeoLite2-City.mmdb + GeoLite2-ASN.mmdb. Point it at a read-only mount to bring your own databases. Default /opt/geolite2."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
+6
View File
@@ -171,6 +171,10 @@ async function main() {
// "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted"
// "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted"
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
// v1.33.1 adds the gateway bearer; it is named in the array below only.
// Quoting a column name in this comment would satisfy the coverage guard
// in encrypted-columns.test.ts without the script rotating anything, so
// the newest entry stays unquoted here on purpose.
const userFields = [
"codexAccessTokenEncrypted",
"codexRefreshTokenEncrypted",
@@ -200,6 +204,8 @@ async function main() {
"aiAnthropicKeyEncrypted",
"aiLocalKeyEncrypted",
"aiOpenaiKeyEncrypted",
// v1.33.1 (#470) — the OpenAI-compatible gateway's optional bearer.
"aiCompatKeyEncrypted",
"insuranceNumberEncrypted",
// v1.23 — TOTP shared secret (second factor).
"totpSecretEncrypted",
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* The OpenAI-compatible gateway provider (#470) needs no CSP change, and this
* pins that.
*
* The reasoning is worth stating because the opposite conclusion is the
* tempting one: the gateway's host is user-configured, so if the browser ever
* had to reach it, `connect-src` would need a wildcard — which would undo the
* surgical gating the AI settings surface has today. It does not: the
* provider is resolved and called server-side (`/api/ai/test`,
* `/api/insights/*`), so the browser only ever talks to this origin.
*
* If a future change makes the settings page call a gateway directly, this
* test goes red, and that is the moment to have the argument.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function setNodeEnv(value: "development" | "production") {
vi.stubEnv("NODE_ENV", value);
}
beforeEach(() => {
setNodeEnv("production");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function connectSrcFor(pathname: string): string {
const res = proxy(
new NextRequest(`http://localhost${pathname}`, {
headers: { cookie: "healthlog_session=sess-1" },
}),
);
const csp = res.headers.get("content-security-policy") ?? "";
const directive = csp
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith("connect-src"));
return directive ?? "";
}
describe("AI settings connect-src is untouched by the gateway provider", () => {
it("still allows exactly the two AI hosts it allowed before", () => {
expect(connectSrcFor("/settings/ai")).toBe(
"connect-src 'self' https://api.openai.com https://chatgpt.com",
);
});
it("adds no AI host anywhere else", () => {
expect(connectSrcFor("/dashboard")).toBe("connect-src 'self'");
});
});
@@ -0,0 +1,131 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
AUTH_MODELS_OUT_OF_SCOPE,
BACKED_UP_MODELS,
DERIVED_MODELS,
NOT_IN_BACKUP_MODELS,
backupVerdict,
} from "@/lib/export/backup-plan";
/**
* Every model in the schema has a written verdict about the backup.
*
* This is the first half of the backup guard: classification. The second half
* — that every `BACKED_UP` model actually has a payload reader AND a restore
* branch — DOES NOT EXIST YET. It is the harder half and the one that matters,
* because a model classified as carried and then never restored is the defect
* rather than the fix, and this test would pass either way.
*
* Until it lands, `BACKED_UP` means "must be carried", not "is carried".
*
* The failure this prevents: a model is added to `schema.prisma`, it is
* user-scoped from birth, and it is outside the backup by default. Nothing
* notices, and the gap is only discovered by someone restoring a backup and
* finding a part of their record missing — which is the worst possible moment
* and the worst possible messenger.
*/
const SCHEMA_PATH = resolve(__dirname, "../../prisma/schema.prisma");
/** Models declared in the schema, minus `User` itself (the account row). */
function schemaModels(): string[] {
const source = readFileSync(SCHEMA_PATH, "utf8");
return [...source.matchAll(/^model\s+(\w+)\s*\{/gm)]
.map((m) => m[1])
.filter((name) => name !== "User");
}
/**
* Models with no `userId` and no user relation are instance-scoped (queue
* tables, rate-limit buckets, invite codes the operator issues). They are not
* an account's data and the wipe plan already declares them as such.
*/
function isUserScoped(model: string): boolean {
const source = readFileSync(SCHEMA_PATH, "utf8");
const block = new RegExp(
`^model\\s+${model}\\s*\\{([\\s\\S]*?)^\\}`,
"m",
).exec(source);
if (!block) return false;
const body = block[1];
return /\buserId\b/.test(body) || /\buser\s+User\b/.test(body);
}
describe("every schema model has a backup verdict", () => {
const models = schemaModels();
it("reads a schema with models in it (no vacuous pass)", () => {
expect(models.length).toBeGreaterThan(50);
});
it("classifies every user-scoped model", () => {
const unclassified = models
.filter(isUserScoped)
.filter((m) => backupVerdict(m) === null);
if (unclassified.length > 0) {
throw new Error(
`${unclassified.length} user-scoped model(s) have no backup verdict:\n\n` +
unclassified.map((m) => ` ❌ ${m}`).join("\n") +
"\n\nAdd each to src/lib/export/backup-plan.ts: to BACKED_UP_MODELS if an " +
"account would lose something real without it, to DERIVED_MODELS with what " +
"rebuilds it, or to NOT_IN_BACKUP_MODELS with why it must not travel. " +
"A new model is user-scoped from birth and outside the backup by default, " +
"which is exactly the silence this test exists to break.",
);
}
});
it("carries no verdict for a model the schema no longer has", () => {
const known = new Set(models);
const stale = [
...BACKED_UP_MODELS,
...Object.keys(DERIVED_MODELS),
...Object.keys(NOT_IN_BACKUP_MODELS),
...AUTH_MODELS_OUT_OF_SCOPE,
].filter((m) => !known.has(m));
expect(
stale,
`verdicts for models that no longer exist: ${stale.join(", ")}`,
).toEqual([]);
});
it("gives each model exactly one verdict", () => {
const seen = new Map<string, string[]>();
const add = (model: string, list: string) => {
seen.set(model, [...(seen.get(model) ?? []), list]);
};
for (const m of BACKED_UP_MODELS) add(m, "BACKED_UP");
for (const m of Object.keys(DERIVED_MODELS)) add(m, "DERIVED");
for (const m of Object.keys(NOT_IN_BACKUP_MODELS)) add(m, "NOT_IN_BACKUP");
for (const m of AUTH_MODELS_OUT_OF_SCOPE) add(m, "AUTH");
const doubled = [...seen.entries()].filter(([, lists]) => lists.length > 1);
expect(
doubled.map(([m, lists]) => `${m}: ${lists.join(" + ")}`),
"a model classified twice means two readers can each believe the other handles it",
).toEqual([]);
});
it("gives every excluded model a reason, not an empty string", () => {
const empty = [
...Object.entries(DERIVED_MODELS),
...Object.entries(NOT_IN_BACKUP_MODELS),
]
// 20 characters let "Same as WithingsConnection." through, which is a
// pointer rather than a reason: if the entry it points at changes, this
// one silently inherits something else. 60 forces the decision to be
// written where it is made.
.filter(([, reason]) => reason.trim().length < 60)
.map(([model]) => model);
expect(
empty,
"a one-word reason is how a decision stops being reviewable",
).toEqual([]);
});
});
@@ -0,0 +1,175 @@
import { readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
/**
* A one-shot DOM read must be gated on the element it measures, not on the
* network.
*
* The failure this catches, twice on consecutive CI runs during v1.33.0:
* a spec navigates with `waitUntil: "networkidle"` and then reads the DOM in
* a single `page.evaluate` / `evaluateAll` that never retries. An idle
* network says nothing about whether React has rendered, so the read can land
* on a tree that is still mounting. The assertion then blames the application
* — "language + dob fields must exist", "the helper is measuring the wrong
* element" — for what is a race inside the test.
*
* Why this guard lives in the unit suite rather than in Playwright: e2e runs
* at PR-CI only, and `playwright.config.ts` sets `failOnFlakyTests` on CI, so
* one unguarded read costs a whole red pipeline before anyone sees it. Here it
* is red in seconds, on the machine that wrote it.
*
* It is deliberately a source-shape check rather than a runtime one. The
* shape is what a developer gets wrong; the runtime symptom only appears on a
* loaded runner, which is precisely where it is most expensive to discover.
*/
const E2E_DIR = resolve(__dirname, "../../e2e");
/**
* Sites where a `goto` → `evaluate` with no locator gate is correct, each with
* the reason. Keep this list short: it is an inventory of exceptions, not a
* place to put anything inconvenient. Past ~15 entries the guard is asking the
* wrong question and should be rethought rather than extended.
*/
const ALLOWLIST: ReadonlyArray<{ file: string; test: string; why: string }> = [
{
file: "ipad-viewport.spec.ts",
test: "no horizontal overflow and full-width content on",
why: "Deliberate swallowed waitForFunction. The rail settle can land just after networkidle on a loaded runner, but a genuine regression — the rail never engaging — still fails at the width assertion below, which reports the actual measured number. A hard gate here would replace that number with a generic timeout message, so the swallow buys a better diagnostic rather than hiding a race.",
},
];
interface Offence {
file: string;
testName: string;
line: number;
}
/** Source positions of every `test(` / `test.only(` block opener. */
function testBlocks(
source: string,
): Array<{ name: string; start: number; end: number }> {
const blocks: Array<{ name: string; start: number; end: number }> = [];
const opener = /\btest(?:\.only|\.fixme|\.skip)?\s*\(\s*(`|"|')([\s\S]*?)\1/g;
const starts: Array<{ name: string; index: number }> = [];
for (let m = opener.exec(source); m !== null; m = opener.exec(source)) {
starts.push({ name: m[2], index: m.index });
}
for (let i = 0; i < starts.length; i += 1) {
blocks.push({
name: starts[i].name,
start: starts[i].index,
end: i + 1 < starts.length ? starts[i + 1].index : source.length,
});
}
return blocks;
}
const GOTO = /\bpage\.goto\s*\(/g;
/** A read that samples the DOM once and does not retry. */
const ONE_SHOT_READ =
/\b(?:page|\w+)\.(?:evaluate|evaluateAll|evaluateHandle|\$\$eval|\$eval)\s*\(/;
/**
* Anything that waits for the DOM to reach a state before the read.
*
* No leading `\b`: the common form is `page.locator(…).first().waitFor(…)`,
* where the character before the dot is `)`. There is no word boundary
* between `)` and `.`, so a `\b` here silently fails to match the most
* frequent gate in the suite and reports correct tests as offenders. That is
* worse than no guard, because the fix someone reaches for is to allowlist a
* test that was never wrong.
*/
const GATE =
/(?:await\s+expect(?:\.poll|\.soft)?\s*\(|\.waitFor\s*\(|waitForSelector\s*\(|waitForFunction\s*\(|settleBeforeMeasure\s*\(|settleForOverflowMeasurement\s*\(|toBeVisible\s*\(|toBeAttached\s*\()/;
function lineOf(source: string, index: number): number {
return source.slice(0, index).split("\n").length;
}
/**
* A wait whose rejection is thrown away is not a gate.
*
* `await page.waitForFunction(...).catch(() => {})` reads like a guarantee and
* is not one: on timeout it proceeds to the measurement exactly as if it had
* never waited. Counting it would also leave the guard trivially defeatable —
* append `.catch(() => {})` to any wait and the check goes quiet. Where the
* swallow is deliberate, the allowlist is the place to say so.
*/
const SWALLOWED_WAIT =
/(?:waitForFunction|waitForSelector|waitFor)\s*\([\s\S]*?\)\s*\n?\s*\.catch\s*\(/;
function stripSwallowedWaits(segment: string): string {
return segment.replace(new RegExp(SWALLOWED_WAIT, "g"), "«swallowed»");
}
function scan(file: string, source: string): Offence[] {
const offences: Offence[] = [];
for (const block of testBlocks(source)) {
const body = source.slice(block.start, block.end);
GOTO.lastIndex = 0;
for (let g = GOTO.exec(body); g !== null; g = GOTO.exec(body)) {
const after = body.slice(g.index);
const read = ONE_SHOT_READ.exec(after);
if (!read) continue;
const between = stripSwallowedWaits(after.slice(0, read.index));
if (GATE.test(between)) continue;
offences.push({
file,
testName: block.name,
line: lineOf(source, block.start + g.index),
});
break;
}
}
return offences;
}
describe("e2e specs gate their measurements on the element, not the network", () => {
const specs = readdirSync(E2E_DIR).filter((f) => f.endsWith(".spec.ts"));
it("finds spec files to check (the guard must not pass vacuously)", () => {
expect(specs.length).toBeGreaterThan(10);
});
it("has no ungated goto → one-shot DOM read outside the allowlist", () => {
const offences: Offence[] = [];
for (const spec of specs) {
const source = readFileSync(join(E2E_DIR, spec), "utf8");
offences.push(...scan(spec, source));
}
const unexplained = offences.filter(
(o) =>
!ALLOWLIST.some(
(a) => a.file === o.file && o.testName.includes(a.test),
),
);
if (unexplained.length > 0) {
throw new Error(
`${unexplained.length} e2e test(s) navigate and then read the DOM once, with nothing in between that waits for the element:\n\n` +
unexplained
.map((o) => ` ${o.file}:${o.line} — "${o.testName}"`)
.join("\n") +
"\n\nAn idle network does not mean React has rendered. Gate on the element the " +
"measurement is about (await expect(locator).toBeVisible(), or settleBeforeMeasure " +
"from e2e/utils/settle.ts). If the read genuinely does not need a gate, add it to " +
"ALLOWLIST in this file with the reason.",
);
}
});
it("carries no allowlist entry for a spec file that no longer exists", () => {
for (const entry of ALLOWLIST) {
expect(specs, `stale allowlist entry: ${entry.file}`).toContain(
entry.file,
);
}
});
it("keeps the allowlist small enough to still be an inventory of exceptions", () => {
expect(ALLOWLIST.length).toBeLessThanOrEqual(15);
});
});
@@ -0,0 +1,185 @@
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
/**
* A formatter that renders an instant must say which calendar it renders in.
*
* `new Intl.DateTimeFormat(locale, {…})` and `date.toLocaleDateString(…)` fall
* back to the BROWSER's timezone when none is given. That is a different
* question from the one this app answers: a reading belongs to the day it was
* where the person lives, not where their laptop happens to be. A reporter hit
* exactly this (#490), and the sweep behind it found four separate surfaces
* that had drifted apart — one of them printed a shared report a day off from
* the page it was downloaded from.
*
* The check is a source-shape check, and it is deliberately coarse in one
* direction: it cannot tell a Date built from calendar arithmetic (a month
* label, a weekday header — no instant involved, no timezone question) from a
* Date parsed out of stored data. So those legitimately-bare sites live on the
* allowlist with a reason, and the guard's real job is the ratchet: a NEW bare
* formatter has to be argued for in writing before it can ship.
*
* `.toLocaleString(` is deliberately NOT matched. It is overwhelmingly a
* number formatter in this tree, and flagging thousands separators would bury
* the four real hits in noise until someone silenced the whole guard.
*/
const REPO_ROOT = resolve(__dirname, "../..");
/**
* Bare formatter sites that are correct as they stand. Keep this short: it is
* an inventory of exceptions, not a parking space. Every entry says why.
*/
const ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
{
file: "src/components/ui/calendar.tsx",
why: "A `data-day` attribute on a calendar cell, derived from the grid's own arithmetic. It identifies a cell, it does not render a recorded moment.",
},
{
file: "src/components/cycle/cycle-calendar.tsx",
why: "Weekday headers walked from a fixed local date, and a month label built from a month anchor. Both are calendar arithmetic with no instant behind them.",
},
{
file: "src/lib/intl/formatter-cache.ts",
why: "The shared cache itself. Its options come from the caller, so the timezone is the caller's to supply and the guard should be asking them, not this.",
},
];
const FORMATTER_PATTERNS = [
/new Intl\.DateTimeFormat\s*\(/g,
/\.toLocale(?:Date|Time)String\s*\(/g,
];
/** The argument list starting at the `(` at or after `from`. */
function argumentList(source: string, from: number): string {
const open = source.indexOf("(", from);
if (open === -1) return "";
let depth = 0;
for (let i = open; i < source.length; i += 1) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") {
depth -= 1;
if (depth === 0) return source.slice(open, i + 1);
}
}
return source.slice(open);
}
function bareFormatterSites(): Array<{ file: string; line: number }> {
const listed = execFileSync(
"grep",
[
"-rlE",
"new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(",
"src",
"--include=*.ts",
"--include=*.tsx",
],
{ cwd: REPO_ROOT, encoding: "utf8" },
)
.trim()
.split("\n")
.filter(Boolean)
// Tests pin instants on purpose and are not user-facing surfaces.
.filter((f) => !f.includes("__tests__"));
const hits: Array<{ file: string; line: number }> = [];
for (const file of listed) {
const source = readFileSync(resolve(REPO_ROOT, file), "utf8");
for (const pattern of FORMATTER_PATTERNS) {
pattern.lastIndex = 0;
for (let m = pattern.exec(source); m !== null; m = pattern.exec(source)) {
const args = argumentList(source, m.index + m[0].length - 1);
// The property, not the substring, and both ways of writing it.
//
// `timeZoneName: "short"` is a DISPLAY option — it appends "GMT+1" to
// the output and leaves the zone as the browser's. A plain substring
// check waves it through, and the result then looks MORE
// timezone-aware than a bare formatter while being exactly as wrong.
//
// The shorthand `{ timeZone }` is the common form in this tree (nine
// correct call sites use it), so a colon-only check would report all
// of them and the honest response would be to allowlist them — which
// is how a guard stops guarding.
if (/\btimeZone\s*(?::|,|\}|$)/m.test(args)) continue;
hits.push({
file,
line: source.slice(0, m.index).split("\n").length,
});
}
}
}
return hits;
}
describe("date formatters name the calendar they render in", () => {
const hits = bareFormatterSites();
it("finds formatter sites at all (the guard must not pass vacuously)", () => {
// If this drops to zero the grep has stopped matching and every assertion
// below became free.
const all = execFileSync(
"grep",
[
"-rlE",
"new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(",
"src",
"--include=*.ts",
"--include=*.tsx",
],
{ cwd: REPO_ROOT, encoding: "utf8" },
)
.trim()
.split("\n")
.filter(Boolean);
expect(all.length).toBeGreaterThan(5);
});
it("has no unexplained formatter without an explicit timeZone", () => {
const unexplained = hits.filter(
(h) => !ALLOWLIST.some((a) => a.file === h.file),
);
if (unexplained.length > 0) {
throw new Error(
`${unexplained.length} formatter call(s) render a date without naming a timezone:\n\n` +
unexplained.map((h) => ` ${h.file}:${h.line}`).join("\n") +
"\n\nWithout `timeZone` these follow the browser, not the profile. Pass the " +
"profile timezone (`readStoredTimezone() || DEFAULT_TIMEZONE` on the client, " +
"`resolveUserTimezone(userId)` on the server), or use the shared helpers in " +
"@/lib/format. If the value is calendar arithmetic rather than a recorded " +
"instant, add the file to ALLOWLIST in this test with the reason.",
);
}
});
it("does not accept timeZoneName as a substitute for timeZone", () => {
// `timeZoneName: "short"` appends "GMT+1" to the output and leaves the
// zone as the browser's. The first version of this guard tested for the
// substring "timeZone", which that satisfies — so a formatter that LOOKS
// more timezone-aware than a bare one sailed through while being exactly
// as wrong. An independent read found it; this pins it.
const property = /\btimeZone\s*(?::|,|\}|$)/m;
expect(property.test('{ hour: "2-digit", timeZoneName: "short" }')).toBe(
false,
);
expect(property.test('{ hour: "2-digit", timeZone: tz }')).toBe(true);
expect(property.test("{ timeZone : tz }")).toBe(true);
// The shorthand forms, which are what most of this tree actually writes.
expect(property.test("{ timeZone, year: 'numeric' }")).toBe(true);
expect(property.test("{ ...options, timeZone }")).toBe(true);
});
it("carries no allowlist entry that no longer has a bare formatter", () => {
// A stale exception is worse than none: it documents a decision about code
// that has moved on, and it quietly covers whatever lands in that file next.
for (const entry of ALLOWLIST) {
expect(
hits.some((h) => h.file === entry.file),
`stale allowlist entry — ${entry.file} no longer has a bare formatter`,
).toBe(true);
}
});
});
@@ -58,6 +58,7 @@ interface RestoreResponse {
cycleProfile: number;
labResults: number;
biomarkers: number;
nutrientDays: number;
illnessEpisodes: number;
allergies: number;
familyHistory: number;
@@ -328,6 +329,12 @@ const handler = apiHandler(
const biomarkers = await tx.biomarker.deleteMany({
where: { userId: ownerId },
});
// The export has written nutrient day totals since v1.29 and nothing
// here ever read them back: water and vitamin history was carried in
// the file, discarded on restore, and the restore reported success.
const nutrientDays = await tx.nutrientIntakeDay.deleteMany({
where: { userId: ownerId },
});
if (payload.appSettings) {
const settings = payload.appSettings;
@@ -598,6 +605,34 @@ const handler = apiHandler(
),
),
];
// Re-create the account's own tag definitions first. The lookup
// below used to ask only for the seeded catalogue (`userId: null`),
// so a single custom rated tag made the whole restore throw
// "Unknown mood factor keys" — the file was complete and unusable.
for (const tag of payload.customMoodTags) {
await tx.moodTag.upsert({
where: { key: tag.key },
create: {
...(tag.id ? { id: tag.id } : {}),
userId: ownerId,
key: tag.key,
labelKey: tag.labelKey,
categoryId: tag.categoryId,
kind: tag.kind,
isActive: tag.isActive,
icon: tag.icon ?? null,
sortOrder: tag.sortOrder,
labelEncrypted: tag.labelEncrypted ?? null,
scaleMin: tag.scaleMin,
scaleMax: tag.scaleMax,
inverse: tag.inverse,
},
// `key` is globally unique, so a seeded key would collide.
// Leave the catalogue row alone; the links resolve either way.
update: {},
});
}
const factorRows =
factorKeys.length === 0
? []
@@ -605,7 +640,8 @@ const handler = apiHandler(
where: {
key: { in: factorKeys },
kind: "RATED",
userId: null,
// The seeded catalogue OR this account's own tags.
OR: [{ userId: null }, { userId: ownerId }],
},
select: { id: true, key: true },
});
@@ -968,6 +1004,19 @@ const handler = apiHandler(
});
}
if (payload.nutrientDays.length > 0) {
await tx.nutrientIntakeDay.createMany({
data: payload.nutrientDays.map((n) => ({
userId: ownerId,
day: n.day,
nutrient: n.nutrient,
amount: n.amount,
unit: n.unit,
source: n.source,
})),
});
}
if (payload.documents.length > 0) {
await tx.inboundDocument.createMany({
data: payload.documents.map((document) => ({
@@ -1018,6 +1067,7 @@ const handler = apiHandler(
cycleProfile: cycleCleared.cycleProfile,
labResults: labResults.count,
biomarkers: biomarkers.count,
nutrientDays: nutrientDays.count,
illnessEpisodes: illnessEpisodes.count,
allergies: allergies.count,
familyHistory: familyHistory.count,
@@ -1140,6 +1190,7 @@ const handler = apiHandler(
cycleDayLogs: summary.cycleDayLogs,
labResults: summary.labResults,
biomarkers: summary.biomarkers,
nutrientDays: summary.nutrientDays,
illnessEpisodes: summary.illnessEpisodes,
illnessDayLogs: summary.illnessDayLogs,
allergies: summary.allergies,
@@ -68,6 +68,7 @@ vi.mock("@/lib/crypto", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
@@ -393,4 +394,20 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () =>
expect(args.orderBy).toEqual({ createdAt: "desc" });
expect(args.take).toBe(20);
});
it("excludes reminder-dedup bookkeeping rows so the last 20 are real sends", async () => {
// The medication tick anchors its per-slot dedup in this table under a
// sentinel channel. Letting those rows into the trailing window would
// push the actual delivery attempts — the thing an operator opens this
// endpoint for — off the list.
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
await GET();
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
where: { channel?: { not?: string } };
};
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
});
});
@@ -4,6 +4,7 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import type { ChannelType } from "@/lib/notifications/types";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
export const dynamic = "force-dynamic";
@@ -174,7 +175,10 @@ export const GET = apiHandler(async () => {
// turns the orderBy + take into a single index scan, so the cost is
// bounded regardless of the user's lifetime push volume.
prisma.pushAttempt.findMany({
where: { userId: user.id },
// The reminder tick's per-slot dedup anchors live in this table under
// a sentinel channel. They are bookkeeping, not delivery attempts,
// and would push the real sends out of the trailing window.
where: { userId: user.id, channel: { not: REMINDER_DEDUP_CHANNEL } },
orderBy: { createdAt: "desc" },
take: 20,
select: {
@@ -37,6 +37,7 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
import { NextRequest } from "next/server";
const ADMIN_CTX = {
@@ -139,6 +140,17 @@ describe("GET /api/admin/notifications/health — aggregate", () => {
]);
});
it("excludes reminder-dedup bookkeeping rows from the channel picture", async () => {
// The medication tick anchors its per-slot dedup in the same table
// under a sentinel channel. Those rows are not delivery attempts, so
// counting them would misreport what the channels are doing.
await GET(req());
const args = vi.mocked(prisma.pushAttempt.groupBy).mock.calls[0][0] as {
where: { channel?: { not?: string } };
};
expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL });
});
it("defaults the window to 24h and clamps out-of-range values", async () => {
let res = await GET(req("999"));
let body = (await res.json()) as HealthEnvelope;
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup";
export const dynamic = "force-dynamic";
@@ -58,7 +59,13 @@ export const GET = apiHandler(async (request) => {
const [grouped, disabled] = await Promise.all([
prisma.pushAttempt.groupBy({
by: ["channel", "result"],
where: { createdAt: { gte: since } },
// The reminder tick anchors its per-slot dedup in the same table
// under a sentinel channel. Those rows record a decision, not a
// delivery, so they stay out of the per-channel health picture.
where: {
createdAt: { gte: since },
channel: { not: REMINDER_DEDUP_CHANNEL },
},
_count: { _all: true },
}),
// Channels disabled with an automated reason. A manual disable leaves
@@ -261,6 +261,27 @@ describe("POST /api/ai/test — dropdown-aware override", () => {
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
});
it("forwards the gateway's own override fields (#470)", async () => {
makeProviderThatSucceeds("anthropic/claude-sonnet-4-6");
const response = await POST(
jsonRequest({
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://litellm.example.com/v1",
compatKey: "gateway-secret",
compatModel: "anthropic/claude-sonnet-4-6",
}) as never,
);
expect(response.status).toBe(200);
// The gateway's fields travel under their own names — the pinned OpenAI
// arm reads `baseUrl` / `openaiKey` and must not be reachable from here.
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://litellm.example.com/v1",
compatKey: "gateway-secret",
compatModel: "anthropic/claude-sonnet-4-6",
});
});
it("rejects unknown provider values with 422", async () => {
const response = await POST(
jsonRequest({ provider: "MALICIOUS" }) as never,
+13 -1
View File
@@ -42,7 +42,13 @@ export const dynamic = "force-dynamic";
const overrideSchema = z
.object({
provider: z
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
.enum([
"OPENAI",
"ANTHROPIC",
"LOCAL",
"OPENAI_COMPATIBLE",
"CHATGPT_OAUTH",
])
.optional()
.nullable(),
model: z.string().min(1).max(120).optional().nullable(),
@@ -50,6 +56,12 @@ const overrideSchema = z
anthropicKey: z.string().min(1).max(500).optional().nullable(),
localKey: z.string().min(1).max(500).optional().nullable(),
openaiKey: z.string().min(1).max(500).optional().nullable(),
// v1.33.1 (#470) — the OpenAI-compatible gateway's own fields, kept
// separate from `baseUrl` / `openaiKey` so testing an unsaved gateway
// config can never reach the pinned OpenAI arm and vice versa.
compatBaseUrl: z.string().url().max(2048).optional().nullable(),
compatKey: z.string().min(1).max(500).optional().nullable(),
compatModel: z.string().min(1).max(120).optional().nullable(),
})
.strict();
@@ -16,11 +16,13 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
moodTag: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
cycleSymptom: { findMany: vi.fn() },
// v1.28 backup-completeness — the records section the full-backup
// helper now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn() },
@@ -360,6 +362,7 @@ describe("GET /api/export/mood", () => {
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{
date: "2026-05-01",
@@ -405,9 +408,11 @@ describe("GET /api/export/full-backup", () => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
@@ -473,9 +478,11 @@ describe("GET /api/export/full-backup", () => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never);
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
@@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
moodEntry: { findMany: vi.fn(), count: vi.fn() },
moodTag: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
user: { findUnique: vi.fn() },
passkey: { findMany: vi.fn() },
@@ -14,10 +14,12 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
moodTag: { findMany: vi.fn().mockResolvedValue([]) },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
cycleSymptom: { findMany: vi.fn().mockResolvedValue([]) },
// v1.28 backup-completeness — the records section the shared payload
// builder now also reads (`buildRecordsBackupSection`).
labResult: { findMany: vi.fn().mockResolvedValue([]) },
+13 -9
View File
@@ -11,15 +11,19 @@
* `.json` so the browser writes a `.json` file even though the route
* segment doesn't carry the extension.
*
* Backup-completeness — the payload also carries lab results, the
* biomarker catalog, illness episodes (incl. flares/exacerbations) with
* their day-logs, allergies, family history, and workout summaries. These
* domains are EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore`
* only re-creates the original measurements/medications/mood/cycle
* domains, so restoring a snapshot does not (yet) recreate these sections
* — see `src/lib/export/records-backup.ts`. Document original files and
* workout GPS/sample time series are never included; the payload's
* `manifest` field discloses both, and the export UI states this too.
* Backup completeness — what this file carries, and what it does not, is
* declared in `src/lib/export/backup-plan.ts` rather than described here. A
* sentence beside the code is how the previous version of this comment came
* to be wrong: it said lab results, biomarkers, illness episodes, allergies,
* family history and workouts were EXPORT-ONLY and could not be restored.
* The restore route re-creates all of them, and since v1.33.1 nutrient day
* totals as well.
*
* The gap that IS real: roughly 31 of the models the plan marks `BACKED_UP`
* have neither a reader here nor a restore branch yet. The plan names each
* one. Document original files and workout GPS/sample time series are never
* included; the payload's `manifest` field discloses both, and the export UI
* states this too.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Rate-limit: shared `export:<userId>` bucket (10/h).
@@ -219,6 +219,8 @@ describe("GET /api/integrations/healthkit — sync health", () => {
type Body = {
lastSyncedAt: string | null;
lastSyncTrigger: string | null;
lastBackgroundSyncAt: string | null;
syncHealth: { verdict: string; since: string | null };
metricFreshness: Array<{
type: string;
@@ -293,6 +295,38 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(byType.PULSE).toBe(false);
});
// #586 — the reporter's question is whether the phone delivers on its own or
// only while the app is open. `lastSyncedAt` reads identically either way, so
// it cannot answer it; these two fields are what can.
it("publishes the trigger of the last batch and the last background arrival", async () => {
const backgroundAt = ago(3 * 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
healthKitLastSyncTrigger: "background",
healthKitLastBackgroundSyncAt: backgroundAt,
} as never);
const body = await read();
expect(body.lastSyncTrigger).toBe("background");
expect(body.lastBackgroundSyncAt).toBe(backgroundAt.toISOString());
});
it("reports honest absence when the client has never declared a trigger", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
healthKitLastSyncTrigger: null,
healthKitLastBackgroundSyncAt: null,
} as never);
const body = await read();
// An older client that reports nothing, and a pipe that has never
// delivered in the background, both read as null — never as a guess.
expect(body.lastSyncTrigger).toBeNull();
expect(body.lastBackgroundSyncAt).toBeNull();
});
it("degrades to no per-metric data rather than failing the config read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
+18 -1
View File
@@ -130,12 +130,27 @@ export const GET = apiHandler(async () => {
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { healthKitConfigJson: true, healthKitLastSyncedAt: true },
select: {
healthKitConfigJson: true,
healthKitLastSyncedAt: true,
healthKitLastSyncTrigger: true,
healthKitLastBackgroundSyncAt: true,
},
});
const stored = parseStored(row?.healthKitConfigJson ?? null);
const entries = mergeWithDefaults(stored);
const lastSyncedAt = row?.healthKitLastSyncedAt?.toISOString() ?? null;
// What triggered the most recent accepted batch, and when data last arrived
// without the app being opened. `lastSyncedAt` alone cannot separate those
// two cases, which is the whole diagnostic: a card that only ever says "last
// sync 10 minutes ago" reads the same whether the phone is delivering by
// itself or whether every arrival was the user opening the app. Null in
// either field is honest absence — an older client that declares no trigger,
// or a pipe that has never delivered in the background.
const lastSyncTrigger = row?.healthKitLastSyncTrigger ?? null;
const lastBackgroundSyncAt =
row?.healthKitLastBackgroundSyncAt?.toISOString() ?? null;
// Apple Health is push-based: the iOS app delivers and nothing here polls, so
// liveness is a data question, not an attempt question. Without a threshold a
@@ -157,6 +172,8 @@ export const GET = apiHandler(async () => {
return apiSuccess({
entries,
lastSyncedAt,
lastSyncTrigger,
lastBackgroundSyncAt,
syncHealth,
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
});
@@ -153,7 +153,12 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)",
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { healthKitLastSyncedAt: expect.any(Date) },
// This batch declares no trigger, so the trigger field is nulled and the
// background timestamp is not written — see the sync-trigger suite.
data: {
healthKitLastSyncedAt: expect.any(Date),
healthKitLastSyncTrigger: null,
},
});
});
@@ -3,8 +3,8 @@
* batch ingest.
*
* It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event
* so an operator can see, per batch, what woke the client (foreground app open,
* background refresh, or a push), and NOTHING else. These pin that contract:
* and kept on the account, where the Apple Health card reads it. These pin that
* contract:
*
* - a batch carrying `syncTrigger:"background"` is accepted and the value
* rides the ingest annotation;
@@ -12,7 +12,11 @@
* - an invalid value is a 422 (closed enum);
* - the field never changes which rows are inserted / updated — the per-row
* outcomes are byte-identical with and without it, so it cannot leak into
* dedup, attribution, or storage.
* dedup, attribution, or storage;
* - the trigger is stamped on the account, and a background or push trigger
* additionally dates the last background arrival. That second timestamp is
* the whole diagnostic (#586): `healthKitLastSyncedAt` alone reads the same
* whether the phone delivers on its own or only while the app is open.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -301,3 +305,68 @@ describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", ()
expect(withoutBody.inserted).toBe(1);
});
});
describe("POST /api/measurements/batch — HealthKit trigger persistence (#586)", () => {
function stampedData() {
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as
{ data: Record<string, unknown> } | undefined;
return call?.data;
}
it("dates the last background arrival when the batch says it woke in the background", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "background",
}),
);
expect(res.status).toBe(200);
expect(stampedData()).toMatchObject({
healthKitLastSyncedAt: expect.any(Date),
healthKitLastSyncTrigger: "background",
healthKitLastBackgroundSyncAt: expect.any(Date),
});
});
it("treats a push wake as delivery without the app too", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "push",
}),
);
expect(res.status).toBe(200);
expect(stampedData()).toMatchObject({
healthKitLastSyncTrigger: "push",
healthKitLastBackgroundSyncAt: expect.any(Date),
});
});
it("leaves the background timestamp untouched for a foreground batch", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "foreground",
}),
);
expect(res.status).toBe(200);
const data = stampedData();
expect(data).toMatchObject({ healthKitLastSyncTrigger: "foreground" });
// Not written at all — a sync the user triggered by opening the app is no
// evidence about background delivery, and overwriting the older honest
// timestamp with this one would erase the only answer the card has.
expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt");
});
it("writes null over a stale trigger when the client declares none", async () => {
const res = await POST(
makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }),
);
expect(res.status).toBe(200);
const data = stampedData();
// The field describes THIS sync. Leaving the previous batch's trigger
// standing would attribute it to a batch that never declared one.
expect(data).toMatchObject({ healthKitLastSyncTrigger: null });
expect(data).not.toHaveProperty("healthKitLastBackgroundSyncAt");
});
});
+27 -7
View File
@@ -169,12 +169,14 @@ const batchEntrySchema = z.object({
});
// v1.32.8 (iOS #66) — optional per-REQUEST diagnostic tag naming what woke the
// client for this sync. Purely observational: it flows into the
// `measurement.batch.ingest` wide event and NOTHING else — never persisted,
// never part of the dedup key, never an input to attribution or how a sample is
// stored. Only the client knows its own wake reason, so it is client-asserted
// by necessity; being diagnostic-only, that carries no trust surface. Optional
// so every pre-#66 caller stays byte-for-byte unchanged.
// client for this sync. Purely diagnostic: it flows into the
// `measurement.batch.ingest` wide event and onto the account's HealthKit
// diagnostic columns, which the Apple Health card reads. It is never part of
// the dedup key and never an input to attribution or how a sample is stored —
// sending it or omitting it produces byte-identical row outcomes. Only the
// client knows its own wake reason, so it is client-asserted by necessity;
// being diagnostic-only, that carries no trust surface. Optional so every
// pre-#66 caller stays byte-for-byte unchanged.
const syncTriggerEnum = z.enum(["foreground", "background", "push"]);
const batchPayloadSchema = z.object({
@@ -644,10 +646,28 @@ async function postBatch(request: NextRequest): Promise<Response> {
// This batch route is the native HealthKit ingestion boundary. Stamp only
// after every attempted write has reached a durable, non-failed verdict;
// MANUAL-only and skipped-only batches are not HealthKit syncs.
//
// The trigger the client declared is stamped alongside, and a background- or
// push-triggered batch additionally dates `healthKitLastBackgroundSyncAt`.
// Without that second timestamp there is no way to tell a phone that delivers
// on its own from one that only ever delivers while the app is open — both
// look like a moving `healthKitLastSyncedAt`. A batch that declares no
// trigger writes null over the previous value rather than leaving a stale one
// standing: the field describes THIS sync, and an older build's silence is
// not evidence about it.
if (healthKitSyncSucceeded) {
const syncedAt = new Date();
const deliveredWithoutTheApp =
syncTrigger === "background" || syncTrigger === "push";
await prisma.user.update({
where: { id: user.id },
data: { healthKitLastSyncedAt: new Date() },
data: {
healthKitLastSyncedAt: syncedAt,
healthKitLastSyncTrigger: syncTrigger ?? null,
...(deliveredWithoutTheApp
? { healthKitLastBackgroundSyncAt: syncedAt }
: {}),
},
});
}
@@ -11,16 +11,13 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { prisma } from "@/lib/db";
import {
MEDICATION_INTAKE_IMPORT_QUEUE,
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
type MedicationImportPayload,
type MedicationImportProgress,
} from "@/lib/jobs/medication-intake-import";
import { annotate } from "@/lib/logging/context";
import { admitIntakeImportJob } from "@/lib/medications/intake-import-admission";
import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -181,111 +178,26 @@ export const POST = apiHandler(
touchedDays: [],
rollupProcessed: 0,
};
const admission = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT "id"
FROM "medications"
WHERE "id" = ${id}
AND "user_id" = ${user.id}
FOR UPDATE
`;
const now = new Date();
const staleBefore = new Date(
now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
);
await tx.medicationIntakeImportJob.updateMany({
where: {
userId: user.id,
medicationId: id,
status: { in: ["queued", "running"] },
OR: [
{ heartbeatAt: { lt: staleBefore } },
{
heartbeatAt: null,
startedAt: { lt: staleBefore },
},
{
heartbeatAt: null,
startedAt: null,
createdAt: { lt: staleBefore },
},
],
},
data: {
status: "failed",
failureReason: "Medication intake import abandoned",
heartbeatAt: now,
completedAt: now,
},
});
const activeJob = await tx.medicationIntakeImportJob.findFirst({
where: {
userId: user.id,
medicationId: id,
status: { in: ["queued", "running"] },
},
select: { id: true },
});
if (activeJob) return { active: true as const };
const importJob = await tx.medicationIntakeImportJob.create({
data: {
userId: user.id,
medicationId: id,
status: "queued",
payload: toJson(normalized),
progress: toJson(progress),
},
});
return { active: false as const, importJob };
const admission = await admitIntakeImportJob({
userId: user.id,
medicationId: id,
payload: normalized,
progress,
ipAddress: getClientIp(request),
});
if (admission.active) {
if (!admission.admitted) {
return apiError("Medication intake import already in progress", 409);
}
const { importJob } = admission;
let bossJobId: string | null = null;
try {
const boss = getGlobalBoss();
if (!boss) throw new Error("worker unavailable");
bossJobId = await boss.send(
MEDICATION_INTAKE_IMPORT_QUEUE,
{ jobId: importJob.id },
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
);
if (!bossJobId) throw new Error("queue rejected job");
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
} catch {
const completedAt = new Date();
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: {
status: "failed",
failureReason: "Background worker enqueue failed",
heartbeatAt: completedAt,
completedAt,
},
});
await auditLog("medication.intake.import.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
medicationId: id,
reason: "enqueue_failed",
},
});
if (!admission.enqueued) {
return apiError("Background worker is not available", 503);
}
const { jobId, bossJobId } = admission;
await auditLog("medication.intake.import.kickoff", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
jobId,
bossJobId,
medicationId: id,
total: normalized.entries.length,
@@ -298,16 +210,16 @@ export const POST = apiHandler(
entity_id: id,
},
meta: {
job_id: importJob.id,
job_id: jobId,
total: normalized.entries.length,
},
});
return apiSuccess(
{
jobId: importJob.id,
jobId,
status: "queued" as const,
statusUrl: `/api/medications/${id}/intake/import/${importJob.id}/status`,
statusUrl: `/api/medications/${id}/intake/import/${jobId}/status`,
},
202,
);
@@ -32,18 +32,15 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { prisma } from "@/lib/db";
import {
MEDICATION_INTAKE_IMPORT_QUEUE,
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
groupMedicationImportSkips,
totalMedicationImportSkips,
type MedicationImportPayload,
type MedicationImportProgress,
} from "@/lib/jobs/medication-intake-import";
import { annotate } from "@/lib/logging/context";
import { admitIntakeImportJob } from "@/lib/medications/intake-import-admission";
import {
parseAutoExportCsv,
parseAutoExportJson,
@@ -190,104 +187,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
rollupProcessed: 0,
};
const admission = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT "id"
FROM "users"
WHERE "id" = ${user.id}
FOR UPDATE
`;
const now = new Date();
const staleBefore = new Date(
now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
);
await tx.medicationIntakeImportJob.updateMany({
where: {
userId: user.id,
medicationId: null,
status: { in: ["queued", "running"] },
OR: [
{ heartbeatAt: { lt: staleBefore } },
{ heartbeatAt: null, startedAt: { lt: staleBefore } },
{
heartbeatAt: null,
startedAt: null,
createdAt: { lt: staleBefore },
},
],
},
data: {
status: "failed",
failureReason: "Medication intake import abandoned",
heartbeatAt: now,
completedAt: now,
},
});
const activeJob = await tx.medicationIntakeImportJob.findFirst({
where: {
userId: user.id,
medicationId: null,
status: { in: ["queued", "running"] },
},
select: { id: true },
});
if (activeJob) return { active: true as const };
const importJob = await tx.medicationIntakeImportJob.create({
data: {
userId: user.id,
medicationId: null,
status: "queued",
payload: toJson(payload),
progress: toJson(progress),
},
});
return { active: false as const, importJob };
const admission = await admitIntakeImportJob({
userId: user.id,
medicationId: null,
payload,
progress,
ipAddress: getClientIp(request),
});
if (admission.active) {
if (!admission.admitted) {
return apiError("A dose-history import is already in progress", 409);
}
const { importJob } = admission;
const statusUrl = `/api/medications/intake/dose-history-import/${importJob.id}/status`;
let bossJobId: string | null = null;
try {
const boss = getGlobalBoss();
if (!boss) throw new Error("worker unavailable");
bossJobId = await boss.send(
MEDICATION_INTAKE_IMPORT_QUEUE,
{ jobId: importJob.id },
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
);
if (!bossJobId) throw new Error("queue rejected job");
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
} catch {
const completedAt = new Date();
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: {
status: "failed",
failureReason: "Background worker enqueue failed",
heartbeatAt: completedAt,
completedAt,
},
});
await auditLog("medication.intake.import.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: { jobId: importJob.id, reason: "enqueue_failed" },
});
if (!admission.enqueued) {
return apiError("Background worker is not available", 503);
}
const { jobId, bossJobId } = admission;
const statusUrl = `/api/medications/intake/dose-history-import/${jobId}/status`;
await auditLog("medication.intake.history.import.kickoff", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
jobId,
bossJobId,
rowsRead: file.rowsRead,
queued: file.queued,
@@ -297,7 +218,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "medication.intake.history.import.kickoff" },
meta: {
job_id: importJob.id,
job_id: jobId,
rows_read: file.rowsRead,
queued: file.queued,
refused: file.refused,
@@ -307,7 +228,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess(
{
dryRun: false,
jobId: importJob.id,
jobId,
status: "queued" as const,
statusUrl,
file,
+2 -2
View File
@@ -22,6 +22,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { binaryReferenceSex } from "@/lib/profile/sex";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
NUTRIENT_CATALOG,
@@ -72,8 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
daySeries.push({ day: key, amount: sumByDay.get(key) ?? 0 });
}
const sex =
user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null;
const sex = binaryReferenceSex(user.gender);
const reference = resolveNutrientReference(nutrient, sex);
annotate({
@@ -46,6 +46,8 @@ vi.mock("@/lib/logging/context", () => ({
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { resolveProviderAvailability } from "@/lib/ai/provider";
import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
function patchRequest(body: unknown): Request {
return new Request("http://localhost/api/user/ai-provider", {
@@ -192,3 +194,110 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => {
);
});
});
// v1.33.1 (#470) — the OpenAI-compatible gateway writes three columns of its
// own. What matters at this layer: the SSRF floor applies to the gateway's
// base URL exactly as it does to the Local one, and the column is nobody
// else's — switching the active provider away must not hand a stale URL to a
// key-carrying provider, and must not silently discard the user's gateway
// either.
describe("PATCH /api/user/ai-provider — the gateway's own columns (#470)", () => {
const patch = PATCH as (req: Request) => Promise<Response>;
it("persists base URL, model and encrypted key", async () => {
vi.mocked(isPublicUrl).mockReturnValue(true);
const res = await patch(
patchRequest({
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://litellm.example.com/v1",
compatModel: "anthropic/claude-sonnet-4-6",
compatKey: "gateway-secret",
}),
);
expect(res.status).toBe(200);
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
aiProvider: "OPENAI_COMPATIBLE",
aiCompatBaseUrl: "https://litellm.example.com/v1",
aiCompatModel: "anthropic/claude-sonnet-4-6",
aiCompatKeyEncrypted: "enc:gateway-secret",
}),
}),
);
});
it("refuses a private base URL the operator has not allowlisted", async () => {
vi.mocked(isPublicUrl).mockReturnValue(false);
vi.mocked(isLocalAiHostAllowed).mockReturnValue(false);
const res = await patch(
patchRequest({ compatBaseUrl: "http://169.254.169.254/v1" }),
);
expect(res.status).toBe(422);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("accepts a private base URL the operator allowlisted", async () => {
vi.mocked(isPublicUrl).mockReturnValue(false);
vi.mocked(isLocalAiHostAllowed).mockReturnValue(true);
const res = await patch(
patchRequest({ compatBaseUrl: "http://litellm.lan:4000/v1" }),
);
expect(res.status).toBe(200);
expect(vi.mocked(prisma.user.update)).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
aiCompatBaseUrl: "http://litellm.lan:4000/v1",
}),
}),
);
});
it("leaves the gateway URL alone when the provider switches to OpenAI", async () => {
vi.mocked(isPublicUrl).mockReturnValue(true);
await patch(patchRequest({ provider: "OPENAI" }));
const data = vi.mocked(prisma.user.update).mock.calls[0][0].data as Record<
string,
unknown
>;
// `aiBaseUrl` is the shared LOCAL column and IS cleared. The gateway's is
// untouched — it belongs to one provider, and that provider is not this
// one, so there is nothing for a stale value to redirect.
expect(data.aiBaseUrl).toBeNull();
expect(data).not.toHaveProperty("aiCompatBaseUrl");
});
});
describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => {
it("reports the base URL and model, and the key as presence only", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: "OPENAI_COMPATIBLE",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: "https://litellm.example.com/v1",
aiCompatKeyEncrypted: "enc-gateway",
aiCompatModel: "anthropic/claude-sonnet-4-6",
aiResponseTimeoutSeconds: null,
} as never);
vi.mocked(resolveProviderAvailability).mockResolvedValue({
aiAvailable: true,
managedBy: "user",
});
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()) as {
data: {
compatBaseUrl: string | null;
compatModel: string | null;
hasCompatKey: boolean;
};
};
expect(body.data.compatBaseUrl).toBe("https://litellm.example.com/v1");
expect(body.data.compatModel).toBe("anthropic/claude-sonnet-4-6");
expect(body.data.hasCompatKey).toBe(true);
expect(JSON.stringify(body)).not.toContain("enc-gateway");
});
});
+58 -1
View File
@@ -10,7 +10,15 @@ import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]);
const ALLOWED = new Set([
"OPENAI",
"ANTHROPIC",
"LOCAL",
// v1.33.1 (#470) — the OpenAI-compatible gateway (LiteLLM / OpenRouter /
// vLLM). Its base URL, key and model live in their own three columns.
"OPENAI_COMPATIBLE",
"CHATGPT_OAUTH",
]);
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -25,6 +33,10 @@ export const GET = apiHandler(async () => {
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
// v1.33.1 (#470) — the OpenAI-compatible gateway.
aiCompatBaseUrl: true,
aiCompatKeyEncrypted: true,
aiCompatModel: true,
// v1.22 (#89)
aiResponseTimeoutSeconds: true,
},
@@ -52,6 +64,11 @@ export const GET = apiHandler(async () => {
openaiKeyPreview: u?.aiOpenaiKeyEncrypted
? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}`
: null,
// v1.33.1 (#470) — the gateway's own configuration. The key is reported
// as presence only, like every other credential on this surface.
compatBaseUrl: u?.aiCompatBaseUrl ?? null,
compatModel: u?.aiCompatModel ?? null,
hasCompatKey: Boolean(u?.aiCompatKeyEncrypted),
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
});
@@ -112,6 +129,46 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
}
}
// ── v1.33.1 (#470) — the OpenAI-compatible gateway's three fields ──
// Deliberately a separate block from `baseUrl` above: that column is shared
// with LOCAL and is cleared when the provider switches away from it. This
// one belongs to a single provider and to no other, which is what keeps the
// OpenAI key from ever being sendable to a user-supplied host.
if (body.compatBaseUrl !== undefined) {
if (body.compatBaseUrl === null || body.compatBaseUrl === "") {
updates.aiCompatBaseUrl = null;
} else if (typeof body.compatBaseUrl === "string") {
const trimmed = body.compatBaseUrl.trim();
// Same SSRF floor as the Local provider: a public host always, a
// private one only when the operator allowlisted it. Gateways on a LAN
// are the normal case for LiteLLM / vLLM, so the escape hatch matters
// here as much as it does for Ollama.
if (!isLocalAiHostAllowed(trimmed) && !isPublicUrl(trimmed)) {
return apiError(
"Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. litellm.lan) or to true for any private host (intended for a self-hosted gateway).",
422,
);
}
updates.aiCompatBaseUrl = trimmed;
}
}
if (body.compatModel !== undefined) {
if (body.compatModel === null || body.compatModel === "") {
updates.aiCompatModel = null;
} else if (typeof body.compatModel === "string") {
updates.aiCompatModel = body.compatModel.trim() || null;
}
}
if (body.compatKey !== undefined) {
if (body.compatKey === null || body.compatKey === "") {
updates.aiCompatKeyEncrypted = null;
} else if (typeof body.compatKey === "string") {
updates.aiCompatKeyEncrypted = encrypt(body.compatKey.trim());
}
}
if (body.anthropicKey !== undefined) {
if (body.anthropicKey === null || body.anthropicKey === "") {
updates.aiAnthropicKeyEncrypted = null;
@@ -43,6 +43,27 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() }));
// Wraps rather than replaces: the real renderer still runs, so the route is
// exercised end to end, and the options it was handed stay observable.
const pdfOptionsSeen: Array<Record<string, unknown>> = [];
vi.mock("@/lib/doctor-report-pdf-core", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/doctor-report-pdf-core")>();
return {
...actual,
renderDoctorReportPdfBytes: (
data: Parameters<typeof actual.renderDoctorReportPdfBytes>[0],
options: Parameters<typeof actual.renderDoctorReportPdfBytes>[1],
) => {
pdfOptionsSeen.push(options as unknown as Record<string, unknown>);
return actual.renderDoctorReportPdfBytes(data, options);
},
};
});
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
vi.mock("@/lib/i18n/server-translator", async (importOriginal) => {
const actual =
@@ -60,6 +81,8 @@ import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { prisma } from "@/lib/db";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { selectionFromLeaves } from "@/lib/report-selection/selection";
import type { DoctorReportData } from "@/lib/doctor-report-data";
@@ -127,6 +150,12 @@ beforeEach(() => {
documents: [],
documentOnly: false,
} as never);
// West of UTC on purpose. Berlin is the display fallback, so a Berlin
// fixture cannot tell a resolved timezone apart from a forgotten one.
vi.mocked(resolveUserTimezone).mockResolvedValue("America/Los_Angeles");
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timeFormat: "H24",
} as never);
});
describe("GET /c/{token}/fhir", () => {
@@ -255,6 +284,38 @@ describe("GET /c/{token}/report.pdf", () => {
expect(buf.subarray(0, 5).toString("latin1")).toBe("%PDF-");
});
it("prints the owner's day, not the display default", async () => {
// The defect: the route handed the renderer no timezone at all, so it fell
// back to the display default. A reading is dated by the day it was where
// the person lives, and a practice filing the PDF beside the page it came
// from must not find two different dates for one reading.
pdfOptionsSeen.length = 0;
const { GET } = await import("../report.pdf/route");
await GET(new Request(`http://localhost/c/${TOKEN}/report.pdf`), params);
expect(pdfOptionsSeen).toHaveLength(1);
expect(
pdfOptionsSeen[0].userTz,
"the renderer was handed no owner timezone — it will fall back to the display default",
).toBe("America/Los_Angeles");
expect(
pdfOptionsSeen[0].timeFormat,
"the owner's clock preference did not reach the renderer",
).toBe("H24");
});
it("falls back to AUTO when the owner row carries no clock preference", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
pdfOptionsSeen.length = 0;
const { GET } = await import("../report.pdf/route");
const res = await GET(
new Request(`http://localhost/c/${TOKEN}/report.pdf`),
params,
);
expect(res.status).toBe(200);
expect(pdfOptionsSeen[0].timeFormat).toBe("AUTO");
});
it("runs the same gate as the bundle beside it", async () => {
vi.mocked(verifyUnlockValue).mockReturnValue(false);
const { GET } = await import("../report.pdf/route");
+8
View File
@@ -34,6 +34,14 @@ export const GET = apiHandler(
const bytes = renderDoctorReportPdfBytes(resolved.report, {
t,
locale,
// The locale above is the reader's; these two are the OWNER's, and they
// have to be. A reading belongs to the day it was that day where the
// person was, so the date beside it cannot follow whoever opens the
// link. Without these the renderer fell back to the display default,
// which meant a practice west of Berlin could file a PDF dated one day
// off from the page it was downloaded from.
userTz: resolved.ownerTz,
timeFormat: resolved.ownerTimeFormat,
insuranceNumber: null,
includeCharts: true,
});
@@ -41,6 +41,7 @@ import { queryKeys } from "@/lib/query-keys";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { apiGet } from "@/lib/api/api-fetch";
import { readStoredTimezone } from "@/lib/timezone-mirror";
interface HostMetricsApiSample {
capturedAt: string;
@@ -94,10 +95,15 @@ export function buildChartRows(samples: HostMetricsApiSample[]): {
if (total > peakDiskBps) peakDiskBps = total;
}
// The profile's timezone, not the browser's. These are real instants (host
// samples), and an operator reading their own instance should see the same
// clock here as on every other timestamp in the admin area — a spike at
// "14:20" must not be 14:20 here and 15:20 two screens away.
const formatter = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: readStoredTimezone() || undefined,
});
let hasDiskData = false;
@@ -55,6 +55,10 @@ describe("pickMoodBucket()", () => {
});
});
// Berlin was the module's hard-coded calendar, so these expectations were
// always Berlin's. Naming it here keeps that visible now it is a parameter.
const BERLIN = "Europe/Berlin";
describe("aggregateMoodEntries()", () => {
it("returns daily points unchanged for short windows", () => {
const entries = [
@@ -62,7 +66,7 @@ describe("aggregateMoodEntries()", () => {
{ date: "2026-05-02", score: 4 },
{ date: "2026-05-03", score: 5 },
];
const out = aggregateMoodEntries(entries);
const out = aggregateMoodEntries(entries, BERLIN);
expect(out.bucket).toBe("day");
expect(out.points).toHaveLength(3);
expect(out.points.map((p) => p.score)).toEqual([3, 4, 5]);
@@ -71,7 +75,7 @@ describe("aggregateMoodEntries()", () => {
it("aggregates to weekly mean for windows in the 91-730d range", () => {
// 100-day span — pickBucket → "week".
const entries = dayEntries("2026-01-01", 100, 4);
const out = aggregateMoodEntries(entries);
const out = aggregateMoodEntries(entries, BERLIN);
expect(out.bucket).toBe("week");
// Constant input score = mean of every weekly bucket should be the
// same constant (independent of partial-week edge effects).
@@ -85,7 +89,7 @@ describe("aggregateMoodEntries()", () => {
it("aggregates to monthly mean for windows > 730 days", () => {
const entries = dayEntries("2024-01-01", 800, 5);
const out = aggregateMoodEntries(entries);
const out = aggregateMoodEntries(entries, BERLIN);
expect(out.bucket).toBe("month");
// 800 days ≈ 27 months — well below the daily count.
expect(out.points.length).toBeLessThan(50);
@@ -97,7 +101,10 @@ describe("aggregateMoodEntries()", () => {
});
it("emits empty points array for empty input", () => {
expect(aggregateMoodEntries([])).toEqual({ bucket: "day", points: [] });
expect(aggregateMoodEntries([], BERLIN)).toEqual({
bucket: "day",
points: [],
});
});
});
+2 -2
View File
@@ -1130,7 +1130,7 @@ export function HealthChart({
types.map((type) => [type, p[type] as number | undefined]),
),
})),
{ bucket: bucketType },
{ bucket: bucketType, timeZone: userTimezone },
).points.map<ChartDataPoint>((point) => {
const date = new Date(point.timestamp);
const out: ChartDataPoint = {
@@ -1199,7 +1199,7 @@ export function HealthChart({
}
return enriched;
}, [data, visibleSlice, showMA, showTrend, types, tzFmt]);
}, [data, visibleSlice, showMA, showTrend, types, tzFmt, userTimezone]);
// v1.12.8 — chart-reactive metric statistics.
//
+20 -4
View File
@@ -20,6 +20,8 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { readStoredTimezone } from "@/lib/timezone-mirror";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import type { DataSummary } from "@/lib/analytics/trends";
import {
bucketTimeSeries,
@@ -244,7 +246,16 @@ export function pickMoodBucket(entries: MoodEntryDay[]): ChartBucketType {
* Pure & deterministic so the unit test can pin the exact aggregated
* mean per bucket.
*/
export function aggregateMoodEntries(entries: MoodEntryDay[]): {
export function aggregateMoodEntries(
entries: MoodEntryDay[],
/**
* The calendar the week / month boundaries are drawn in. Required rather
* than defaulted for the same reason `bucketTimeSeries` requires it: a
* default would leave a forgotten caller quietly bucketing someone else's
* evening into the wrong month.
*/
timeZone: string,
): {
bucket: ChartBucketType;
points: Array<{ timestamp: number; score: number }>;
} {
@@ -268,7 +279,7 @@ export function aggregateMoodEntries(entries: MoodEntryDay[]): {
timestamp: dayKeyToTimestamp(entry.date),
values: { score: entry.score },
})),
{ bucket },
{ bucket, timeZone },
);
return {
@@ -309,6 +320,11 @@ export function MoodChart({
// encoded day paints verbatim in every profile zone (a profile-tz
// formatter shifted week/month bucket labels for zones west of Berlin).
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
// The bucket BOUNDARIES follow the profile, even though the bucket LABELS
// stay UTC-pinned (see above): a week that starts on the wrong day puts a
// reading in the wrong bar, which is a different mistake from a label that
// reads a few hours off.
const profileTimeZone = readStoredTimezone() || DEFAULT_TIMEZONE;
const initialRangePoints = windowOverride
? MINI_RANGE_POINTS[windowOverride]
: 30;
@@ -453,7 +469,7 @@ export function MoodChart({
timestamp: p.timestamp,
values: { score: p.score },
})),
{ bucket: bucketType },
{ bucket: bucketType, timeZone: profileTimeZone },
).points.map((point) => ({
date: tzFmt.dateShortSmart(new Date(point.timestamp)),
timestamp: point.timestamp,
@@ -507,7 +523,7 @@ export function MoodChart({
}
return enriched;
}, [data, rangePoints, showMA, showTrend, tzFmt]);
}, [data, rangePoints, showMA, showTrend, tzFmt, profileTimeZone]);
/**
* v1.4.16 B8 — comparison overlay merged into chartData.
+7 -6
View File
@@ -38,6 +38,7 @@ import Link from "next/link";
import { Moon } from "lucide-react";
import { useMounted } from "@/hooks/use-mounted";
import { formatTime } from "@/lib/format";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
@@ -73,17 +74,17 @@ function formatDelta(
* the hydration render — React #418, a class of bug this project has already
* paid for once.
*
* `undefined` locale lets the browser pick the user's own regional format
* (24-hour here, 12-hour there); the chip is a timestamp, not copy.
* Goes through the shared `formatTime`, which reads the profile's timezone and
* clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces
* asked the BROWSER instead, which is a different question: someone whose
* profile says Europe/Berlin while their laptop sits in Manila saw a Manila
* time here and a Berlin time on every other surface, for the same instant.
*/
function formatJustInTime(iso: string): string | null {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return null;
try {
return at.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
return formatTime(at);
} catch {
return null;
}
@@ -65,6 +65,38 @@ describe("<DocumentCard>", () => {
expect(html).not.toContain("Download only");
});
it("announces and shows the Delete shortcut when the card is deletable", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
onDelete={noop}
highlighted={false}
/>,
);
// The keyboard contract used to live only in a code comment. It is now
// announced to assistive tech and drawn while the card holds focus.
expect(html).toContain('aria-keyshortcuts="Delete"');
expect(html).toContain('data-slot="document-delete-hint"');
expect(html).toContain("Del");
});
it("offers no delete shortcut when the card carries no delete handler", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain("aria-keyshortcuts");
expect(html).not.toContain('data-slot="document-delete-hint"');
});
it("shows the download-only badge for attachment-class documents", () => {
const html = render(
<DocumentCard
@@ -6,6 +6,7 @@ import {
buildVaultListApiSearch,
classifyUploadFailure,
countActiveFilters,
documentCardKeyAction,
documentDateKey,
expandRangeSelection,
formatBytes,
@@ -411,3 +412,22 @@ describe("isRecentlyUploaded / isDocumentProcessing / hasProcessingDocument", ()
expect(hasProcessingDocument([], NOW)).toBe(false);
});
});
describe("documentCardKeyAction", () => {
it("deletes on Delete and selects on Space", () => {
expect(documentCardKeyAction("Delete")).toBe("delete");
expect(documentCardKeyAction(" ")).toBe("select");
});
it("does NOT delete on Backspace", () => {
// The reflex "go back" key on a card that takes focus by default, with
// no other delete control and only a transient undo toast behind it.
expect(documentCardKeyAction("Backspace")).toBeNull();
});
it("ignores every other key", () => {
for (const key of ["Enter", "Escape", "a", "ArrowDown", "Del"]) {
expect(documentCardKeyAction(key)).toBeNull();
}
});
});
+34 -3
View File
@@ -29,6 +29,7 @@ import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import {
documentCardKeyAction,
documentDateKey,
formatBytes,
isDocumentProcessing,
@@ -241,7 +242,15 @@ export function DocumentCard({
(the checkbox stays reachable via its own z-10). Keyboard contract
(roving tabindex owned by the timeline): Enter opens (native
click), Space toggles selection, Delete removes with undo. Touch:
press-and-hold selects instead of opening. */}
press-and-hold selects instead of opening.
Backspace is deliberately NOT a delete key here. It is the reflex
"go back" key, the roving tabindex hands a card focus the moment
someone tabs into the grid, and a card carries no other delete
control — so the reflex press destroyed a medical document with
nothing but a transient undo toast to catch it. Delete alone,
announced through `aria-keyshortcuts` and shown as a `<kbd>` chip
while the card holds focus. */}
<button
type="button"
data-slot="document-open"
@@ -254,10 +263,11 @@ export function DocumentCard({
onOpen(document.id);
}}
onKeyDown={(e) => {
if (e.key === " ") {
const action = documentCardKeyAction(e.key);
if (action === "select") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
} else if (action === "delete") {
e.preventDefault();
onDelete?.(document.id);
}
@@ -279,8 +289,29 @@ export function DocumentCard({
onPrefetch?.(document.id);
}}
aria-label={t("documents.card.openLabel", { title })}
aria-keyshortcuts={onDelete ? "Delete" : undefined}
className="focus-visible:ring-ring/50 absolute inset-0 rounded-xl focus-visible:ring-[3px] focus-visible:outline-none"
/>
{/* The only visible affordance for deleting a single card. Shown while
the card holds focus (same `group-focus-within` trigger the checkbox
uses) and hidden from the accessibility tree, because
`aria-keyshortcuts` on the overlay button already announces the same
key. `pointer-events-none` keeps it out of the click target.
Full `text-foreground`, not `text-muted-foreground`: the muted token
on this card's background measures 4.06:1, under the 4.5:1 floor, and
axe fails the vault on it. A key hint is the one piece of meta text
that has to be readable at a glance, so the usual muted-for-meta rule
gives way here rather than the contrast one. */}
{onDelete ? (
<kbd
aria-hidden
data-slot="document-delete-hint"
className="border-border bg-background text-foreground pointer-events-none absolute right-2 bottom-2 hidden rounded border px-1.5 font-mono text-xs opacity-0 transition-opacity group-focus-within:opacity-100 sm:inline-block"
>
{t("documents.card.deleteKey")}
</kbd>
) : null}
</Card>
);
}
+19
View File
@@ -416,3 +416,22 @@ export function parseUploadResponse(
}
return classifyUploadFailure(status, envelope?.meta);
}
/** What a keypress on a focused document card does. */
export type DocumentCardKeyAction = "select" | "delete" | null;
/**
* Resolve the action for a keypress on a focused vault card.
*
* `Backspace` is deliberately absent. The timeline's roving tabindex hands a
* card focus the moment someone tabs into the grid, `Backspace` is the reflex
* "go back" key, and a card carries no other delete control — so the reflex
* press deleted a medical document with nothing but a transient undo toast
* between the person and the loss. `Delete` alone deletes, and the card shows
* that key while it holds focus.
*/
export function documentCardKeyAction(key: string): DocumentCardKeyAction {
if (key === " ") return "select";
if (key === "Delete") return "delete";
return null;
}
@@ -11,6 +11,7 @@ import {
Scale,
} from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { readStoredTimezone } from "@/lib/timezone-mirror";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
@@ -219,12 +220,21 @@ export function HealthScoreCard({
// we fall through to the raw ISO so the user still sees something.
// The formatter is memoised per locale so the row map below doesn't
// build a fresh `Intl.DateTimeFormat` instance per row.
// The timezone is as load-bearing as the locale and was missing: without it
// the formatter used the browser's, so a reading taken late in the evening
// could print yesterday's date for someone whose profile timezone is east of
// where their laptop happens to be. The locale still decides the separator
// convention; the timezone decides which day it was.
const asOfFormatter = useMemo(
() =>
new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "2-digit",
// `readStoredTimezone` returns "" when nothing is stored or during
// SSR, and an empty string makes Intl throw RangeError rather than
// fall back. `undefined` is the value that means "use the default".
timeZone: readStoredTimezone() || undefined,
}),
[locale],
);
@@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { binaryReferenceSex } from "@/lib/profile/sex";
import {
NUTRIENT_CODES,
isNutrientCode,
@@ -51,8 +52,7 @@ export function MicronutrientsCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const { user } = useAuth();
const sex =
user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null;
const sex = binaryReferenceSex(user?.gender);
// A failed read left `data` undefined, `rows` empty, and the card rendered
// its "no micronutrient data" EmptyState — the same output as an account that
@@ -77,7 +77,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "de"): string {
}
const src = readFileSync(
resolve(__dirname, "../sections/inventory-section.tsx"),
resolve(__dirname, "../sections/inventory-dialogs.tsx"),
"utf8",
);
@@ -21,7 +21,7 @@ import { I18nProvider } from "@/lib/i18n/context";
const src = readFileSync(
resolve(
process.cwd(),
"src/components/medications/sections/inventory-section.tsx",
"src/components/medications/sections/inventory-dialogs.tsx",
),
"utf8",
);
@@ -0,0 +1,666 @@
"use client";
/**
* The supply tab's write dialogs: register a container, correct one, and edit
* the medication-level packaging the register flow prefills from.
*
* They sit beside the list rather than inside it because they are the other
* half of the tab: the list answers "what do I hold", these answer "change
* it". Sharing one file made a single client component out of two concerns
* that only meet at the row shape, and the shape now lives in
* `./inventory-shared`.
*
* Both dates a container carries are entered — and stored — as a calendar day
* at the browser's local midnight, so the conversions live here too, next to
* the fields that produce them.
*/
import { useId, useState, type FormEvent } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { startsInUseClock } from "@/lib/medications/inventory/clock-container-types";
import {
CONTAINER_TYPES,
invalidateSupplyQueries,
type ContainerType,
type InventoryItem,
} from "./inventory-shared";
/**
* Both dates the row carries are entered — and were always stored — as a
* calendar day at the browser's local midnight. Reading them back in the
* same zone is what makes the value the user typed the value they see;
* pinning to UTC would slide the day for anyone east of Greenwich.
*/
export function toLocalDayInput(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** The inverse: a `yyyy-MM-dd` day back to the instant the API stores. */
export function fromLocalDayInput(day: string): string | null {
if (!day) return null;
const at = new Date(`${day}T00:00:00`);
return Number.isNaN(at.getTime()) ? null : at.toISOString();
}
/** Default container kind for the register flow, from the delivery form. */
export function defaultContainerType(
deliveryForm: string | undefined,
): ContainerType {
if (deliveryForm === "INJECTION") return "PEN";
if (deliveryForm === "ORAL") return "BLISTER";
return "OTHER";
}
/**
* Register a new pack / container. Quantity (units), container type and
* optional printed expiry → Dialog per ui-guidelines §2.3. The quantity
* prefills from the medication's `dosesPerUnit` so the common case is
* one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented
* Dosen | Einheiten control converts live — the POST always carries
* units.
*/
export function AddInventoryDialog({
medicationId,
defaultUnitsTotal,
unitsPerDose,
initialContainerType,
onClose,
}: {
medicationId: string;
defaultUnitsTotal: number | null;
unitsPerDose: number;
initialContainerType: ContainerType;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [quantity, setQuantity] = useState(
defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000
? String(defaultUnitsTotal)
: "",
);
// The unit the typed quantity is read in. Only surfaced when a dose
// spans several units; the stored value is ALWAYS units.
const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units");
const [containerType, setContainerType] =
useState<ContainerType>(initialContainerType);
const [expiry, setExpiry] = useState("");
// Carton labelling. Only offered for a PEN: the native pen list is the
// one surface that renders them, and asking for a maker + strength on a
// blister pack would be noise on the far commoner path.
const [manufacturer, setManufacturer] = useState("");
const [doseStrength, setDoseStrength] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
const parsed = Number(quantity);
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
async function submit(e: FormEvent) {
e.preventDefault();
if (!quantityValid || busy) return;
setBusy(true);
try {
await apiPost(`/api/medications/${medicationId}/inventory`, {
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsTotal: units,
containerType,
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
// Trimmed to null so a field the user opened and left blank stores
// absence rather than an empty string.
manufacturer: manufacturer.trim() || null,
doseStrength: doseStrength.trim() || null,
});
await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.addSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.addFailed"));
} finally {
setBusy(false);
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.addTitle")}
description={t("medications.detail.bestand.addDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!quantityValid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.addSubmit")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-container-type"
className="text-sm font-medium"
>
{t("medications.detail.bestand.containerTypeLabel")}
</label>
<Select
value={containerType}
onValueChange={(v) => setContainerType(v as ContainerType)}
>
<SelectTrigger
id="inventory-add-container-type"
className="w-full"
data-slot="inventory-container-type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTAINER_TYPES.map((ct) => (
<SelectItem key={ct} value={ct}>
{t(`medications.detail.bestand.containerType.${ct}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
{unitsPerDose > 1 && (
<div
className="border-border/60 inline-flex rounded-md border p-0.5"
role="group"
aria-label={t("medications.detail.bestand.addQuantityLabel")}
data-slot="inventory-quantity-mode"
>
<Button
type="button"
size="sm"
variant={quantityMode === "doses" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "doses"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("doses")}
>
{t("medications.detail.bestand.quantityModeDoses")}
</Button>
<Button
type="button"
size="sm"
variant={quantityMode === "units" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "units"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("units")}
>
{t("medications.detail.bestand.quantityModeUnits")}
</Button>
</div>
)}
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={
effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000
}
step={1}
required
autoComplete="off"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
aria-describedby="inventory-add-quantity-helper"
/>
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
<p
className="text-muted-foreground text-xs"
data-slot="inventory-quantity-conversion"
>
{effectiveMode === "doses"
? t("medications.detail.bestand.quantityInUnits", { units })
: // A unit count below one dose must not read "≈ 0 doses" —
// it is simply less than one dose.
Math.floor(parsed / unitsPerDose) === 0
? t("medications.detail.bestand.quantityUnderOneDose")
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
})}
</p>
)}
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.addQuantityHelper")}
</p>
</div>
<div className="space-y-2">
<label htmlFor="inventory-add-expiry" className="text-sm font-medium">
{t("medications.detail.bestand.addExpiryLabel")}
</label>
<DateField
id="inventory-add-expiry"
value={expiry}
onChange={setExpiry}
/>
</div>
{containerType === "PEN" && (
<>
<div className="space-y-2">
<label
htmlFor="inventory-add-manufacturer"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addManufacturerLabel")}
</label>
<Input
id="inventory-add-manufacturer"
value={manufacturer}
maxLength={120}
onChange={(e) => setManufacturer(e.target.value)}
/>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-dose-strength"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addDoseStrengthLabel")}
</label>
<Input
id="inventory-add-dose-strength"
value={doseStrength}
maxLength={60}
onChange={(e) => setDoseStrength(e.target.value)}
/>
<p className="text-muted-foreground text-sm">
{t("medications.detail.bestand.addDoseStrengthHint")}
</p>
</div>
</>
)}
</form>
</ResponsiveSheet>
);
}
/**
* Correct one container: the remaining-unit count (covers both "I
* miscounted" and a manual withdrawal), the carton-printed expiry, and
* the date it was opened. The server clamps the count to the item's
* capacity and re-derives the state from the two dates; 0 units marks
* the item used up.
*
* Both dates are editable because both decide the state — and the
* opening date in particular is written by the intake consumption hook
* without the user ever typing it, so an auto-open on a back-dated dose
* needs a way back. Each field is sent only when it actually changed,
* so saving a unit correction never rewrites a date the user did not
* touch.
*/
export function AdjustInventoryDialog({
medicationId,
item,
onClose,
}: {
medicationId: string;
item: InventoryItem;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty,
// not the literal "null"; the operator types the corrected figure.
const [value, setValue] = useState(
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
);
const initialExpiry = toLocalDayInput(item.printedExpiry);
const initialOpened = toLocalDayInput(item.firstUseAt);
const [expiry, setExpiry] = useState(initialExpiry);
const [opened, setOpened] = useState(initialOpened);
const [busy, setBusy] = useState(false);
const formId = useId();
const parsed = Number(value);
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
// v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness.
const valid =
value.trim() !== "" &&
Number.isFinite(parsed) &&
parsed >= 0 &&
(item.unitsTotal == null || parsed <= item.unitsTotal);
async function submit(e: FormEvent) {
e.preventDefault();
if (!valid || busy) return;
setBusy(true);
try {
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsRemaining: parsed,
// Absent means untouched on the server, so only changed fields
// ride along. A cleared field sends null, which is a deliberate
// "there is no such date" — not the same as leaving it alone.
...(expiry !== initialExpiry && {
printedExpiry: fromLocalDayInput(expiry),
}),
...(opened !== initialOpened && {
markAsFirstUseAt: fromLocalDayInput(opened),
}),
});
await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.adjustSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.adjustFailed"));
} finally {
setBusy(false);
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.adjustTitle")}
description={t("medications.detail.bestand.adjustDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!valid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.adjustSubmit")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-adjust-remaining"
className="text-sm font-medium"
>
{t("medications.detail.bestand.adjustQuantityLabel")}
</label>
<Input
id="inventory-adjust-remaining"
type="number"
inputMode="decimal"
min={0}
max={item.unitsTotal ?? undefined}
step="any"
required
autoComplete="off"
value={value}
onChange={(e) => setValue(e.target.value)}
aria-describedby="inventory-adjust-helper"
/>
<p
id="inventory-adjust-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total: item.unitsTotal ?? t("medications.detail.bestand.unknown"),
})}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-edit-expiry"
className="text-sm font-medium"
>
{t("medications.detail.bestand.editExpiryLabel")}
</label>
<DateField
id="inventory-edit-expiry"
value={expiry}
onChange={setExpiry}
aria-describedby="inventory-edit-expiry-helper"
data-testid="inventory-edit-expiry"
/>
<p
id="inventory-edit-expiry-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.editExpiryHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-edit-opened"
className="text-sm font-medium"
>
{t("medications.detail.bestand.editOpenedLabel")}
</label>
<DateField
id="inventory-edit-opened"
value={opened}
onChange={setOpened}
aria-describedby="inventory-edit-opened-helper"
data-testid="inventory-edit-opened"
/>
<p
id="inventory-edit-opened-helper"
className="text-muted-foreground text-xs"
>
{startsInUseClock(item.containerType)
? t("medications.detail.bestand.editOpenedHelperClock")
: t("medications.detail.bestand.editOpenedHelper")}
</p>
</div>
</form>
</ResponsiveSheet>
);
}
/**
* Edit the medication-level packaging economics from the supply tab:
* units one dose consumes, and the default container size the register
* flow prefills. Both PUT sparsely onto the medication — the wizard's
* dose step stays the source on create; this is the correction surface
* for a manufacturer switch (same medication, different blister size).
*/
export function PackagingDialog({
medicationId,
unitsPerDose,
dosesPerUnit,
onClose,
}: {
medicationId: string;
unitsPerDose: number;
dosesPerUnit: number | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose));
const [packValue, setPackValue] = useState(
dosesPerUnit === null ? "" : String(dosesPerUnit),
);
const [busy, setBusy] = useState(false);
const formId = useId();
const parsedPerDose = Number(perDoseValue);
const perDoseValid =
Number.isInteger(parsedPerDose) &&
parsedPerDose >= 1 &&
parsedPerDose <= 100;
const parsedPack = packValue.trim() === "" ? null : Number(packValue);
const packValid =
parsedPack === null ||
(Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000);
async function submit(e: FormEvent) {
e.preventDefault();
if (!perDoseValid || !packValid || busy) return;
setBusy(true);
try {
await apiPut(`/api/medications/${medicationId}`, {
unitsPerDose: parsedPerDose,
dosesPerUnit: parsedPack,
});
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.medicationDetail(medicationId),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medications(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
}),
]);
toast.success(t("medications.detail.bestand.packagingSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.packagingFailed"));
} finally {
setBusy(false);
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.packagingTitle")}
description={t("medications.detail.bestand.packagingDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!perDoseValid || !packValid || busy}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="packaging-units-per-dose"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
</label>
<Input
id="packaging-units-per-dose"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={perDoseValue}
onChange={(e) => setPerDoseValue(e.target.value)}
aria-describedby="packaging-units-per-dose-helper"
/>
<p
id="packaging-units-per-dose-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="packaging-default-pack"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingDefaultPackLabel")}
</label>
<Input
id="packaging-default-pack"
type="number"
inputMode="numeric"
min={1}
max={1000}
step={1}
autoComplete="off"
value={packValue}
onChange={(e) => setPackValue(e.target.value)}
aria-describedby="packaging-default-pack-helper"
/>
<p
id="packaging-default-pack-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingDefaultPackHelper")}
</p>
</div>
</form>
</ResponsiveSheet>
);
}
@@ -37,117 +37,45 @@
* doses remaining across every non-terminal item — plus a per-item list
* with its container type and state badge. Shown for ALL medications
* (pill packs count too).
*
* This file is the read half: the summary, the packaging line, and the
* per-item rows. The three write dialogs live in `./inventory-dialogs`,
* and the row shape they both speak in `./inventory-shared`. They are
* re-exported here so the tab keeps one import surface.
*/
import { useId, useState, type FormEvent } from "react";
import { useState } from "react";
import Link from "next/link";
import {
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, PackageOpen, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DeleteButton } from "@/components/data-list/delete-button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DateField } from "@/components/ui/date-field";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/date-format";
import { queryKeys } from "@/lib/query-keys";
import { formatUnitCount } from "@/components/medications/units-per-dose";
import {
apiDelete,
apiGet,
apiPatch,
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
import {
AddInventoryDialog,
AdjustInventoryDialog,
PackagingDialog,
defaultContainerType,
toLocalDayInput,
} from "./inventory-dialogs";
import {
invalidateSupplyQueries,
type InventoryItem,
type InventoryState,
} from "./inventory-shared";
/**
* Invalidate every read key whose payload reflects a medication's supply
* after a container write (register / adjust / delete).
*
* The per-medication inventory read (`medicationInventory`) is the supply
* tab's own list. The medications LIST read (`medications`) carries the
* dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that
* the card and table render — without invalidating it, the card kept
* showing the pre-write stock until an unrelated refetch landed (the
* supply-staleness bug). Both keys must drop together.
*/
export async function invalidateSupplyQueries(
queryClient: QueryClient,
medicationId: string,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medications(),
}),
]);
}
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
const CONTAINER_TYPES = [
"PEN",
"AMPOULE",
"BLISTER",
"INHALER",
"BOTTLE",
"OTHER",
] as const;
type ContainerType = (typeof CONTAINER_TYPES)[number];
interface InventoryItem {
id: string;
state: InventoryState;
containerType: ContainerType;
// v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row);
// the UI renders "—" instead of a fabricated 0.
unitsTotal: number | null;
unitsRemaining: number | null;
/** ISO instant. The carton-printed expiry; null when none was recorded. */
printedExpiry: string | null;
/** ISO instant. When the container was opened; null while sealed. */
firstUseAt: string | null;
}
/**
* Both dates the row carries are entered — and were always stored — as a
* calendar day at the browser's local midnight. Reading them back in the
* same zone is what makes the value the user typed the value they see;
* pinning to UTC would slide the day for anyone east of Greenwich.
*/
function toLocalDayInput(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** The inverse: a `yyyy-MM-dd` day back to the instant the API stores. */
function fromLocalDayInput(day: string): string | null {
if (!day) return null;
const at = new Date(`${day}T00:00:00`);
return Number.isNaN(at.getTime()) ? null : at.toISOString();
}
export { AddInventoryDialog, AdjustInventoryDialog } from "./inventory-dialogs";
export { invalidateSupplyQueries } from "./inventory-shared";
interface InventoryResponse {
items: InventoryItem[];
@@ -169,22 +97,6 @@ const STATE_BADGE: Record<
USED_UP: "outline",
};
/**
* Container kinds whose first opening starts a 30-day degradation clock.
* Mirrors `IN_USE_CLOCK_CONTAINER_TYPES` in
* `src/lib/medications/inventory/state-machine.ts` — the server owns the
* transition; the client only needs to know which helper text to show
* beside the opening date.
*/
const CLOCK_CONTAINER_TYPES: readonly ContainerType[] = ["PEN", "AMPOULE"];
/** Default container kind for the register flow, from the delivery form. */
function defaultContainerType(deliveryForm: string | undefined): ContainerType {
if (deliveryForm === "INJECTION") return "PEN";
if (deliveryForm === "ORAL") return "BLISTER";
return "OTHER";
}
export function InventorySection({
medicationId,
dosesPerUnit,
@@ -544,596 +456,3 @@ export function InventorySection({
</div>
);
}
/**
* Register a new pack / container. Quantity (units), container type and
* optional printed expiry → Dialog per ui-guidelines §2.3. The quantity
* prefills from the medication's `dosesPerUnit` so the common case is
* one tap. For multi-unit doses (`unitsPerDose > 1`) a segmented
* Dosen | Einheiten control converts live — the POST always carries
* units.
*/
export function AddInventoryDialog({
medicationId,
defaultUnitsTotal,
unitsPerDose,
initialContainerType,
onClose,
}: {
medicationId: string;
defaultUnitsTotal: number | null;
unitsPerDose: number;
initialContainerType: ContainerType;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [quantity, setQuantity] = useState(
defaultUnitsTotal && defaultUnitsTotal >= 1 && defaultUnitsTotal <= 1000
? String(defaultUnitsTotal)
: "",
);
// The unit the typed quantity is read in. Only surfaced when a dose
// spans several units; the stored value is ALWAYS units.
const [quantityMode, setQuantityMode] = useState<"units" | "doses">("units");
const [containerType, setContainerType] =
useState<ContainerType>(initialContainerType);
const [expiry, setExpiry] = useState("");
// Carton labelling. Only offered for a PEN: the native pen list is the
// one surface that renders them, and asking for a maker + strength on a
// blister pack would be noise on the far commoner path.
const [manufacturer, setManufacturer] = useState("");
const [doseStrength, setDoseStrength] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
const parsed = Number(quantity);
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
const units = effectiveMode === "doses" ? parsed * unitsPerDose : parsed;
const quantityValid =
Number.isInteger(parsed) && parsed >= 1 && units >= 1 && units <= 1000;
async function submit(e: FormEvent) {
e.preventDefault();
if (!quantityValid || busy) return;
setBusy(true);
try {
await apiPost(`/api/medications/${medicationId}/inventory`, {
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsTotal: units,
containerType,
printedExpiry: expiry
? new Date(`${expiry}T00:00:00`).toISOString()
: null,
// Trimmed to null so a field the user opened and left blank stores
// absence rather than an empty string.
manufacturer: manufacturer.trim() || null,
doseStrength: doseStrength.trim() || null,
});
await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.addSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.addFailed"));
} finally {
setBusy(false);
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.addTitle")}
description={t("medications.detail.bestand.addDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!quantityValid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.addSubmit")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-container-type"
className="text-sm font-medium"
>
{t("medications.detail.bestand.containerTypeLabel")}
</label>
<Select
value={containerType}
onValueChange={(v) => setContainerType(v as ContainerType)}
>
<SelectTrigger
id="inventory-add-container-type"
className="w-full"
data-slot="inventory-container-type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTAINER_TYPES.map((ct) => (
<SelectItem key={ct} value={ct}>
{t(`medications.detail.bestand.containerType.${ct}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
{unitsPerDose > 1 && (
<div
className="border-border/60 inline-flex rounded-md border p-0.5"
role="group"
aria-label={t("medications.detail.bestand.addQuantityLabel")}
data-slot="inventory-quantity-mode"
>
<Button
type="button"
size="sm"
variant={quantityMode === "doses" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "doses"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("doses")}
>
{t("medications.detail.bestand.quantityModeDoses")}
</Button>
<Button
type="button"
size="sm"
variant={quantityMode === "units" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "units"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("units")}
>
{t("medications.detail.bestand.quantityModeUnits")}
</Button>
</div>
)}
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={
effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000
}
step={1}
required
autoComplete="off"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
aria-describedby="inventory-add-quantity-helper"
/>
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
<p
className="text-muted-foreground text-xs"
data-slot="inventory-quantity-conversion"
>
{effectiveMode === "doses"
? t("medications.detail.bestand.quantityInUnits", { units })
: // A unit count below one dose must not read "≈ 0 doses" —
// it is simply less than one dose.
Math.floor(parsed / unitsPerDose) === 0
? t("medications.detail.bestand.quantityUnderOneDose")
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
})}
</p>
)}
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.addQuantityHelper")}
</p>
</div>
<div className="space-y-2">
<label htmlFor="inventory-add-expiry" className="text-sm font-medium">
{t("medications.detail.bestand.addExpiryLabel")}
</label>
<DateField
id="inventory-add-expiry"
value={expiry}
onChange={setExpiry}
/>
</div>
{containerType === "PEN" && (
<>
<div className="space-y-2">
<label
htmlFor="inventory-add-manufacturer"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addManufacturerLabel")}
</label>
<Input
id="inventory-add-manufacturer"
value={manufacturer}
maxLength={120}
onChange={(e) => setManufacturer(e.target.value)}
/>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-dose-strength"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addDoseStrengthLabel")}
</label>
<Input
id="inventory-add-dose-strength"
value={doseStrength}
maxLength={60}
onChange={(e) => setDoseStrength(e.target.value)}
/>
<p className="text-muted-foreground text-sm">
{t("medications.detail.bestand.addDoseStrengthHint")}
</p>
</div>
</>
)}
</form>
</ResponsiveSheet>
);
}
/**
* Correct one container: the remaining-unit count (covers both "I
* miscounted" and a manual withdrawal), the carton-printed expiry, and
* the date it was opened. The server clamps the count to the item's
* capacity and re-derives the state from the two dates; 0 units marks
* the item used up.
*
* Both dates are editable because both decide the state — and the
* opening date in particular is written by the intake consumption hook
* without the user ever typing it, so an auto-open on a back-dated dose
* needs a way back. Each field is sent only when it actually changed,
* so saving a unit correction never rewrites a date the user did not
* touch.
*/
export function AdjustInventoryDialog({
medicationId,
item,
onClose,
}: {
medicationId: string;
item: InventoryItem;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.18.3 (iOS#31) — an unknown remaining count (null) pre-fills empty,
// not the literal "null"; the operator types the corrected figure.
const [value, setValue] = useState(
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
);
const initialExpiry = toLocalDayInput(item.printedExpiry);
const initialOpened = toLocalDayInput(item.firstUseAt);
const [expiry, setExpiry] = useState(initialExpiry);
const [opened, setOpened] = useState(initialOpened);
const [busy, setBusy] = useState(false);
const formId = useId();
const parsed = Number(value);
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
// v1.18.3 — when capacity is unknown (null) the only ceiling is finiteness.
const valid =
value.trim() !== "" &&
Number.isFinite(parsed) &&
parsed >= 0 &&
(item.unitsTotal == null || parsed <= item.unitsTotal);
async function submit(e: FormEvent) {
e.preventDefault();
if (!valid || busy) return;
setBusy(true);
try {
await apiPatch(`/api/medications/${medicationId}/inventory/${item.id}`, {
// The wire field carries UNITS (v1.16.10 symmetric naming).
unitsRemaining: parsed,
// Absent means untouched on the server, so only changed fields
// ride along. A cleared field sends null, which is a deliberate
// "there is no such date" — not the same as leaving it alone.
...(expiry !== initialExpiry && {
printedExpiry: fromLocalDayInput(expiry),
}),
...(opened !== initialOpened && {
markAsFirstUseAt: fromLocalDayInput(opened),
}),
});
await invalidateSupplyQueries(queryClient, medicationId);
toast.success(t("medications.detail.bestand.adjustSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.adjustFailed"));
} finally {
setBusy(false);
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.adjustTitle")}
description={t("medications.detail.bestand.adjustDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!valid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.adjustSubmit")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-adjust-remaining"
className="text-sm font-medium"
>
{t("medications.detail.bestand.adjustQuantityLabel")}
</label>
<Input
id="inventory-adjust-remaining"
type="number"
inputMode="decimal"
min={0}
max={item.unitsTotal ?? undefined}
step="any"
required
autoComplete="off"
value={value}
onChange={(e) => setValue(e.target.value)}
aria-describedby="inventory-adjust-helper"
/>
<p
id="inventory-adjust-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total: item.unitsTotal ?? t("medications.detail.bestand.unknown"),
})}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-edit-expiry"
className="text-sm font-medium"
>
{t("medications.detail.bestand.editExpiryLabel")}
</label>
<DateField
id="inventory-edit-expiry"
value={expiry}
onChange={setExpiry}
aria-describedby="inventory-edit-expiry-helper"
data-testid="inventory-edit-expiry"
/>
<p
id="inventory-edit-expiry-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.editExpiryHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-edit-opened"
className="text-sm font-medium"
>
{t("medications.detail.bestand.editOpenedLabel")}
</label>
<DateField
id="inventory-edit-opened"
value={opened}
onChange={setOpened}
aria-describedby="inventory-edit-opened-helper"
data-testid="inventory-edit-opened"
/>
<p
id="inventory-edit-opened-helper"
className="text-muted-foreground text-xs"
>
{CLOCK_CONTAINER_TYPES.includes(item.containerType)
? t("medications.detail.bestand.editOpenedHelperClock")
: t("medications.detail.bestand.editOpenedHelper")}
</p>
</div>
</form>
</ResponsiveSheet>
);
}
/**
* Edit the medication-level packaging economics from the supply tab:
* units one dose consumes, and the default container size the register
* flow prefills. Both PUT sparsely onto the medication — the wizard's
* dose step stays the source on create; this is the correction surface
* for a manufacturer switch (same medication, different blister size).
*/
function PackagingDialog({
medicationId,
unitsPerDose,
dosesPerUnit,
onClose,
}: {
medicationId: string;
unitsPerDose: number;
dosesPerUnit: number | null;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [perDoseValue, setPerDoseValue] = useState(String(unitsPerDose));
const [packValue, setPackValue] = useState(
dosesPerUnit === null ? "" : String(dosesPerUnit),
);
const [busy, setBusy] = useState(false);
const formId = useId();
const parsedPerDose = Number(perDoseValue);
const perDoseValid =
Number.isInteger(parsedPerDose) &&
parsedPerDose >= 1 &&
parsedPerDose <= 100;
const parsedPack = packValue.trim() === "" ? null : Number(packValue);
const packValid =
parsedPack === null ||
(Number.isInteger(parsedPack) && parsedPack >= 1 && parsedPack <= 1000);
async function submit(e: FormEvent) {
e.preventDefault();
if (!perDoseValid || !packValid || busy) return;
setBusy(true);
try {
await apiPut(`/api/medications/${medicationId}`, {
unitsPerDose: parsedPerDose,
dosesPerUnit: parsedPack,
});
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.medicationDetail(medicationId),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medications(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
}),
]);
toast.success(t("medications.detail.bestand.packagingSuccess"));
onClose();
} catch {
toast.error(t("medications.detail.bestand.packagingFailed"));
} finally {
setBusy(false);
}
}
return (
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.packagingTitle")}
description={t("medications.detail.bestand.packagingDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!perDoseValid || !packValid || busy}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="packaging-units-per-dose"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
</label>
<Input
id="packaging-units-per-dose"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={perDoseValue}
onChange={(e) => setPerDoseValue(e.target.value)}
aria-describedby="packaging-units-per-dose-helper"
/>
<p
id="packaging-units-per-dose-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="packaging-default-pack"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingDefaultPackLabel")}
</label>
<Input
id="packaging-default-pack"
type="number"
inputMode="numeric"
min={1}
max={1000}
step={1}
autoComplete="off"
value={packValue}
onChange={(e) => setPackValue(e.target.value)}
aria-describedby="packaging-default-pack-helper"
/>
<p
id="packaging-default-pack-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingDefaultPackHelper")}
</p>
</div>
</form>
</ResponsiveSheet>
);
}
@@ -0,0 +1,63 @@
/**
* The facts the supply tab's two halves both need.
*
* The tab is a list of containers on one side and three write dialogs on the
* other, and they meet at exactly this much: the row shape the API returns,
* the container kinds it can carry, and the cache keys a write has to drop.
* Keeping that here is what lets the two halves live in their own files
* without either importing the other's rendering.
*/
import type { QueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
/**
* Invalidate every read key whose payload reflects a medication's supply
* after a container write (register / adjust / delete).
*
* The per-medication inventory read (`medicationInventory`) is the supply
* tab's own list. The medications LIST read (`medications`) carries the
* dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that
* the card and table render — without invalidating it, the card kept
* showing the pre-write stock until an unrelated refetch landed (the
* supply-staleness bug). Both keys must drop together.
*/
export async function invalidateSupplyQueries(
queryClient: QueryClient,
medicationId: string,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.medicationInventory(medicationId),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.medications(),
}),
]);
}
export type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
export const CONTAINER_TYPES = [
"PEN",
"AMPOULE",
"BLISTER",
"INHALER",
"BOTTLE",
"OTHER",
] as const;
export type ContainerType = (typeof CONTAINER_TYPES)[number];
export interface InventoryItem {
id: string;
state: InventoryState;
containerType: ContainerType;
// v1.18.3 (iOS#31) — null = unknown unit count (corrupt / legacy row);
// the UI renders "—" instead of a fabricated 0.
unitsTotal: number | null;
unitsRemaining: number | null;
/** ISO instant. The carton-printed expiry; null when none was recorded. */
printedExpiry: string | null;
/** ISO instant. When the container was opened; null while sealed. */
firstUseAt: string | null;
}
@@ -12,6 +12,7 @@ import {
widgetWritesBusy,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
// v1.11.2 HIGH-1 — the web Settings list renders one row per WRITABLE id
// (`DASHBOARD_WIDGET_IDS`) MINUS the `IOS_PIN_ONLY_WIDGET_IDS` (writable so
@@ -113,10 +114,14 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
const chartSwitches = html.match(/data-slot="widget-chart-switch"/g) ?? [];
// One tile + one chart switch per WEB-renderable widget (the
// iOS-pin-only ids are filtered out — they have no web render path).
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
expect(chartSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// One tile + one chart switch per row: the WEB-renderable widgets in
// the sortable list, plus the native-only rows below it (issue #581 —
// the iOS-pin-only ids the default layout carries).
const nativeRows = (html.match(/data-slot="native-widget-row"/g) ?? [])
.length;
expect(nativeRows).toBe(IOS_PIN_ONLY_WIDGET_IDS.length);
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT + nativeRows);
expect(chartSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT + nativeRows);
});
});
@@ -236,14 +241,17 @@ describe("reorderWidgets — pure mutation contract", () => {
});
/**
* v1.7.0 W1 — the stored layout now round-trips the 11 iOS-only widget
* ids. The web Settings list has no tile/chart surface for them, so the
* render must SKIP an id with no web component rather than paint an
* unlabelled row with dead toggles.
* v1.7.0 W1 — the stored layout round-trips the iOS-only widget ids. The
* web tile/chart LIST still has no surface for them (no drag handle, no
* reorder, no web render path), so the sortable list must skip them.
*
* issue #581 — but skipping them everywhere left the account holding rows
* it never set and could not change from any surface, so they now appear in
* a separate, clearly-labelled group with their visibility flags.
*/
describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
it("renders only web-known rows when the layout carries iOS-only ids", () => {
// Inject a layout = the 16 web defaults + the 11 iOS-only ids.
describe("<DashboardLayoutSection> — iOS-only ids stay out of the web list (v1.7.0)", () => {
it("keeps iOS-only ids out of the sortable web list", () => {
// Inject a layout = the web defaults + every catalogue-only iOS id.
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
@@ -259,13 +267,13 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// One row (= one tile switch) per WEB-renderable widget — the iOS-only
// ids (and the iOS-pin-only writable ids) are skipped, not rendered as
// raw-id rows.
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// The sortable list is exactly the web-renderable widgets: one drag
// handle each, and no more.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// No iOS-only raw id leaks into the markup as a row label.
// No iOS-only raw id leaks into the markup as a row label — every one
// of them resolves through a translation key.
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(html).not.toContain(`>${iosId}<`);
}
@@ -283,31 +291,134 @@ describe("<DashboardLayoutSection> — iOS-only id skip (v1.7.0)", () => {
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// No web-known rows → zero switches, but the section still renders.
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(0);
// No web-renderable rows → no sortable list at all, but the section
// still renders and every id lands in the native-only group.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(0);
const nativeRows = html.match(/data-slot="native-widget-row"/g) ?? [];
expect(nativeRows).toHaveLength(DASHBOARD_IOS_ONLY_WIDGET_IDS.length);
expect(html).toContain("dashboard-layout");
});
});
/**
* issue #581 — BMI rendered on the native dashboard and was configurable
* from nowhere: the widgets PUT accepts and persists it, the native client
* writes it, and the web Settings list filtered it out because the web has
* no render path for it. The account was left holding
* `{"id":"bmi","visible":false,…}` — a value it never set and could not
* change. The native-only group is where those flags become reachable.
*/
describe("<DashboardLayoutSection> — native-only widget group (issue #581)", () => {
const NATIVE_ONLY_IDS = [
...IOS_PIN_ONLY_WIDGET_IDS,
...DASHBOARD_IOS_ONLY_WIDGET_IDS,
];
it("offers a tile and a chart switch for BMI when the layout carries it", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{
id: "bmi",
visible: false,
tileVisible: true,
order: DEFAULT_DASHBOARD_LAYOUT.widgets.length,
},
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-widget-id="bmi"');
expect(html).toContain('aria-label="BMI — Tile"');
expect(html).toContain('aria-label="BMI — Chart"');
// Reflects the stored flags rather than inventing a default.
const row = html.match(
/data-widget-id="bmi"[\s\S]*?(?=data-slot="native-widget-row"|$)/,
)![0];
expect(row).toContain('data-slot="widget-tile-switch"');
expect(row).toContain('data-slot="widget-chart-switch"');
// ...and it is NOT in the sortable web list (no drag handle for it).
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
});
it("labels the group and says which client draws these widgets", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{ id: "bmi", visible: false, tileVisible: true, order: 99 },
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="native-only-widgets"');
expect(html).toContain("Shown in the mobile app");
expect(html).toContain("The mobile app draws these");
});
it("shows nothing when the layout holds no native-only id", () => {
// The stock web default layout carries the pin-only writable ids but
// no catalogue-only ones; strip both so a pure web account is modelled.
const nativeOnly = new Set<string>(NATIVE_ONLY_IDS);
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => !nativeOnly.has(w.id),
),
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).not.toContain('data-slot="native-only-widgets"');
expect(html).not.toContain("Shown in the mobile app");
});
it("every native-only id has a label key, so no row can render raw", () => {
for (const id of NATIVE_ONLY_IDS) {
expect(
NATIVE_ONLY_WIDGET_LABEL_KEYS[id],
`${id} has no label key — its row would render its raw id`,
).toBeTruthy();
}
});
it("hides a native-only row whose owning module is off", () => {
authState.modules = { recovery: false };
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{ id: "bmi", visible: false, tileVisible: true, order: 99 },
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// `cardioRecovery` belongs to the recovery module and drops out; BMI
// belongs to none and stays.
expect(html).not.toContain('data-widget-id="cardioRecovery"');
expect(html).toContain('data-widget-id="bmi"');
authState.modules = undefined;
});
});
/**
* v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the
* iOS pin PUT validates them) but have NO web render path, so the web
* Settings list must NOT offer a dead toggle for them.
*/
describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2)", () => {
it("renders one fewer row per iOS-pin-only id than the writable id count", () => {
// Default layout carries all 24 writable widgets incl. the 8 pin-only.
it("renders one fewer sortable row per iOS-pin-only id than the writable id count", () => {
// Default layout carries all writable widgets incl. the pin-only ones.
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// Sanity: WEB_RENDERABLE_ROW_COUNT == writable − pin-only.
expect(WEB_RENDERABLE_ROW_COUNT).toBe(
DASHBOARD_WIDGET_IDS.length - IOS_PIN_ONLY_WIDGET_IDS.length,
);
});
it("does not paint a row whose aria-label matches an iOS-pin-only widget label", () => {
it("paints the pin-only labels ONLY in the native-only group", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The pin-only ids reuse `measurements.type*` labels that resolve to
// distinctive English strings; none should appear as a toggle aria-label.
@@ -325,10 +436,17 @@ describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
// The sortable web list must not offer them (issue #581 note: the
// native-only group below it does, which is where the markup match
// now comes from — so anchor on the sortable rows themselves).
const sortableList = html.slice(
0,
html.indexOf('data-slot="native-only-widgets"'),
);
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
// The widget label drives the switch aria-label; if the row were
// rendered the localised label would show up in the markup.
expect(html).not.toContain(`${pinOnlyLabels[id]} — `);
expect(sortableList).not.toContain(`${pinOnlyLabels[id]} — `);
// ...and each is reachable exactly once, in the native-only group.
expect(html).toContain(`data-widget-id="${id}"`);
}
});
});
@@ -354,9 +472,9 @@ describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The achievements row (and its switches) are gone…
expect(html).not.toContain(ACHIEVEMENTS_ARIA);
// …and exactly one fewer web-renderable row renders.
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT - 1);
// …and exactly one fewer sortable row renders.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT - 1);
});
it("keeps an enabled-module widget and core (no-module) widgets shown", () => {
@@ -371,8 +489,8 @@ describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
it("shows every row when the module map is absent (fail-open)", () => {
authState.modules = undefined;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
expect(html).toContain(ACHIEVEMENTS_ARIA);
});
});
@@ -230,3 +230,27 @@ describe("IntegrationStatusPill — a failing provider states its age", () => {
expect(html).toContain("Sync fehlgeschlagen");
});
});
describe("IntegrationStatusPill — accessible name", () => {
/**
* A generic `aria-label` used to sit on the badge. Being a label, it
* REPLACED the accessible name with itself, so a screen reader announced the
* same words for a healthy connection as for a broken one — erasing the one
* distinction the pill exists to draw.
*/
it.each([
["connected", "Connected"],
["error", "Error"],
["parked", "Paused"],
] as const)("lets the %s label be the accessible name", (state, label) => {
const html = render(
<IntegrationStatusPill
state={state}
lastSyncAt={new Date(Date.now() - 12 * 60 * 1000)}
now={new Date()}
/>,
);
expect(html).not.toContain("aria-label=");
expect(html).toContain(label);
});
});
@@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { CompatProviderForm } from "../compat-provider-form";
import {
PROVIDER_TYPES,
DEFAULT_CHAIN,
uiToLegacyProviderEnum,
type UserAIProvider,
} from "../shared";
/**
* The gateway provider has to be reachable from the UI, not only from the
* resolver — #470's whole complaint was that the combination existed nowhere
* a user could configure it. These pin that it is offered in the picker, that
* its form seeds from the saved values, and that a saved bearer is reported
* as presence rather than echoed.
*/
function baseProvider(overrides: Partial<UserAIProvider>): UserAIProvider {
return {
provider: "OPENAI_COMPATIBLE",
model: null,
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: null,
compatModel: null,
hasCompatKey: false,
responseTimeoutSeconds: null,
...overrides,
};
}
function render(userProvider: UserAIProvider | null) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CompatProviderForm userProvider={userProvider} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("the gateway is offered in the provider picker", () => {
it("appears among the selectable provider types", () => {
expect(PROVIDER_TYPES).toContain("openai-compatible");
});
it("is not in the chain a user starts with", () => {
expect(DEFAULT_CHAIN.map((entry) => entry.providerType)).not.toContain(
"openai-compatible",
);
});
it("maps onto its own persisted provider value", () => {
expect(uiToLegacyProviderEnum("openai-compatible")).toBe(
"OPENAI_COMPATIBLE",
);
});
});
describe("CompatProviderForm", () => {
it("seeds the saved base URL and model", () => {
const html = render(
baseProvider({
compatBaseUrl: "https://litellm.example.com/v1",
compatModel: "anthropic/claude-sonnet-4-6",
}),
);
expect(html).toContain('value="https://litellm.example.com/v1"');
expect(html).toContain('value="anthropic/claude-sonnet-4-6"');
});
it("reports a stored bearer as presence, never as a value", () => {
const html = render(
baseProvider({
compatBaseUrl: "https://litellm.example.com/v1",
compatModel: "m",
hasCompatKey: true,
}),
);
// The key input is empty and only its placeholder says one is stored.
expect(html).toContain('placeholder="(saved)"');
});
it("renders empty for a user who has configured nothing", () => {
const html = render(baseProvider({}));
expect(html).toContain('id="ai-compat-base-url"');
expect(html).toContain('id="ai-compat-model"');
expect(html).toContain('id="ai-compat-key"');
});
});
@@ -35,6 +35,7 @@ import { AnthropicProviderForm } from "./anthropic-provider-form";
import { AutoReadCard } from "./auto-read-card";
import { CentralCodexSwitch } from "./central-codex-switch";
import { CodexProviderForm } from "./codex-provider-form";
import { CompatProviderForm } from "./compat-provider-form";
import { FallbackChainCard } from "./fallback-chain-card";
import { LocalProviderForm } from "./local-provider-form";
import { OpenAIProviderForm } from "./openai-provider-form";
@@ -344,6 +345,9 @@ function ProviderConfigCard({
{provider === "local" && (
<LocalProviderForm userProvider={userProvider} />
)}
{provider === "openai-compatible" && (
<CompatProviderForm userProvider={userProvider} />
)}
{provider === "admin-openai" && (
<AdminOpenAIProviderForm hasAdminKey={insightsSettings?.hasAdminKey} />
)}
@@ -0,0 +1,159 @@
"use client";
/* ────────────────────────────────────────────────────────────────
* OpenAI-compatible gateway form (#470) — base URL + optional key +
* model. Three fields of its own, written to three columns of its own:
* the OpenAI provider stays pinned to api.openai.com and cannot see any
* of them, and the gateway never sees the OpenAI key.
* ──────────────────────────────────────────────────────────────── */
import { useRef, useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared";
export function CompatProviderForm({
userProvider,
}: {
userProvider: UserAIProvider | null | undefined;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [ok, setOk] = useState(false);
const submitInFlightRef = useRef(false);
// Seed from the server once per distinct persisted value, the same
// render-time pattern the sibling forms use (no setState-in-effect).
const seededKey =
userProvider != null
? `${userProvider.compatBaseUrl ?? ""}|${userProvider.compatModel ?? ""}`
: null;
const [previousSeed, setPreviousSeed] = useState<string | null>(null);
if (seededKey && seededKey !== previousSeed) {
setPreviousSeed(seededKey);
setBaseUrl(userProvider?.compatBaseUrl ?? "");
setModel(userProvider?.compatModel ?? "");
}
const saveMutation = useMutation({
mutationFn: async () => {
const body: Record<string, unknown> = {
provider: uiToLegacyProviderEnum("openai-compatible"),
compatBaseUrl: baseUrl.trim() || null,
compatModel: model.trim() || null,
};
if (apiKey.trim()) body.compatKey = apiKey.trim();
await apiPatch("/api/user/ai-provider", body);
},
onSuccess: () => {
setOk(true);
setMsg(t("settings.ai.saved"));
setApiKey("");
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
submitInFlightRef.current = true;
saveMutation.mutate();
}
return (
<form
data-testid="ai-provider-config-openai-compatible"
className="space-y-4"
onSubmit={submit}
noValidate
>
<p className="text-muted-foreground text-xs">
{t("settings.ai.compat.description")}
</p>
<div>
<Label htmlFor="ai-compat-base-url">
{t("settings.ai.baseUrlLabel")}
</Label>
<Input
id="ai-compat-base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://litellm.example.com/v1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="ai-compat-key">
{t("settings.ai.compat.keyLabel")}
</Label>
<PasswordInput
id="ai-compat-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
userProvider?.hasCompatKey ? t("settings.ai.savedShort") : ""
}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="ai-compat-model">{t("settings.ai.modelLabel")}</Label>
<Input
id="ai-compat-model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="anthropic/claude-sonnet-4-6"
className="mt-1"
/>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.compat.modelHint")}
</p>
</div>
<div>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
aria-busy={saveMutation.isPending || undefined}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.ai.saveCta")}
</Button>
</div>
{msg && (
<p className={`text-xs ${ok ? "text-success" : "text-destructive"}`}>
{msg}
</p>
)}
</form>
);
}
+15
View File
@@ -32,6 +32,12 @@ export interface UserAIProvider {
hasLocalKey: boolean;
hasOpenaiKey: boolean;
openaiKeyPreview: string | null;
// v1.33.1 (#470) — the OpenAI-compatible gateway's own configuration. Its
// own fields, because its own columns: the OpenAI provider is pinned to
// api.openai.com and shares nothing with a user-supplied host.
compatBaseUrl: string | null;
compatModel: string | null;
hasCompatKey: boolean;
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: number | null;
}
@@ -46,6 +52,7 @@ export const PROVIDER_TYPES = [
"openai",
"anthropic",
"local",
"openai-compatible",
"admin-openai",
] as const;
export type ProviderType = (typeof PROVIDER_TYPES)[number];
@@ -65,6 +72,12 @@ export interface ProviderChainData {
}[];
}
/**
* The chain a user starts with and returns to on reset. `openai-compatible`
* is deliberately absent: it resolves only against a base URL somebody
* configured, so a default entry would be dead weight for everyone else.
* Users add it from the chain editor once their gateway is saved.
*/
export const DEFAULT_CHAIN: readonly ChainEntry[] = [
{ providerType: "codex", enabled: true },
{ providerType: "openai", enabled: true },
@@ -114,6 +127,8 @@ export function uiToLegacyProviderEnum(p: ProviderType): string | null {
return "ANTHROPIC";
case "local":
return "LOCAL";
case "openai-compatible":
return "OPENAI_COMPATIBLE";
case "codex":
case "admin-openai":
default:
@@ -42,7 +42,9 @@ import {
COMPARISON_BASELINES,
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
type DashboardWidgetCatalogueId,
SCORE_RING_IDS,
SCORE_RING_MODULE,
MAX_SELECTED_SCORE_RINGS,
@@ -61,7 +63,10 @@ import {
} from "@/components/ui/select";
import { apiDelete, apiGet, apiPut, ApiError } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules";
import {
NATIVE_ONLY_WIDGET_LABEL_KEYS,
WIDGET_MODULE_BY_ID,
} from "@/lib/dashboard/widget-modules";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -348,7 +353,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
},
});
function toggle(widgetId: DashboardWidgetId, visible: boolean) {
function toggle(widgetId: DashboardWidgetCatalogueId, visible: boolean) {
if (!layout) return;
setDraft({
...layout,
@@ -365,7 +370,10 @@ export function DashboardLayoutSection({ id }: { id: string }) {
* coarse: they wanted a chart visible without the tile (for metrics they
* tracks without wanting the at-a-glance number) or vice versa.
*/
function toggleTile(widgetId: DashboardWidgetId, tileVisible: boolean) {
function toggleTile(
widgetId: DashboardWidgetCatalogueId,
tileVisible: boolean,
) {
if (!layout) return;
setDraft({
...layout,
@@ -911,6 +919,74 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{t("dashboard.dragHandleHint")}
</p>
)}
{/* issue #581 — widgets the mobile app draws and this page does
not. Filtering them out of the list above is right (a toggle
over something the web cannot draw would be a silent no-op),
but the native client materialises them into the SAME stored
layout, so the account ended up holding rows it never set and
could not change from anywhere. They get their own labelled
group: the same two flags, no reordering (placement is the
native client's), and only for ids the layout actually holds —
an account with no native client has nothing to configure here
and sees nothing. */}
{(() => {
const nativeOnlyIds = new Set<string>([
...IOS_PIN_ONLY_WIDGET_IDS,
...DASHBOARD_IOS_ONLY_WIDGET_IDS,
]);
const nativeWidgets = layout.widgets
.filter((w) => nativeOnlyIds.has(w.id))
// Same fail-open module gate as the web list: only an explicit
// `false` hides the row.
.filter((w) => {
const moduleKey = WIDGET_MODULE_BY_ID[w.id];
return !moduleKey || modules?.[moduleKey] !== false;
})
.sort((a, b) => a.order - b.order);
if (nativeWidgets.length === 0) return null;
return (
<div
data-slot="native-only-widgets"
className="border-border/60 mt-6 space-y-2 border-t pt-4"
>
<p className="text-sm font-medium">
{t("dashboard.layoutNativeOnlyTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.layoutNativeOnlyHint")}
</p>
<div className="text-muted-foreground flex items-center gap-2 px-3 pt-2 pb-1 text-xs font-medium tracking-wide uppercase">
<span className="flex-1" aria-hidden="true" />
<span className="w-12 text-center">
{t("dashboard.layoutTileColumn")}
</span>
<span className="w-12 text-center">
{t("dashboard.layoutChartColumn")}
</span>
</div>
{nativeWidgets.map((widget) => {
const label = t(
NATIVE_ONLY_WIDGET_LABEL_KEYS[widget.id] ?? widget.id,
);
return (
<StaticWidgetRow
key={widget.id}
widget={widget}
disabled={saveMutation.isPending}
labels={{
tileColumn: t("dashboard.layoutTileColumn"),
chartColumn: t("dashboard.layoutChartColumn"),
widgetLabel: label,
}}
onToggleTile={toggleTile}
onToggleChart={toggle}
/>
);
})}
</div>
);
})()}
</div>
)}
@@ -1116,6 +1192,66 @@ function SortableWidgetRow({
);
}
/**
* issue #581 — one row in the native-only group: the same two switches as a
* widget row above, without the drag handle or the arrow buttons. Placement
* of these widgets belongs to the client that draws them, so the web offers
* visibility and nothing it cannot honour.
*/
function StaticWidgetRow({
widget,
disabled,
labels,
onToggleTile,
onToggleChart,
}: {
widget: {
id: DashboardWidgetCatalogueId;
visible: boolean;
tileVisible?: boolean;
order: number;
};
disabled: boolean;
labels: { tileColumn: string; chartColumn: string; widgetLabel: string };
onToggleTile: (id: DashboardWidgetCatalogueId, value: boolean) => void;
onToggleChart: (id: DashboardWidgetCatalogueId, value: boolean) => void;
}) {
const tileChecked =
typeof widget.tileVisible === "boolean"
? widget.tileVisible
: widget.visible;
return (
<div
data-slot="native-widget-row"
data-widget-id={widget.id}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
{labels.widgetLabel}
</span>
<div className="flex w-12 justify-center">
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
disabled={disabled}
data-slot="widget-tile-switch"
/>
</div>
<div className="flex w-12 justify-center">
<Switch
checked={widget.visible}
onCheckedChange={(v) => onToggleChart(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
disabled={disabled}
data-slot="widget-chart-switch"
/>
</div>
</div>
);
}
/**
* v1.27.8 — sortable row for one SELECTED hero score ring. Mirrors the
* `SortableWidgetRow` interaction exactly (drag handle + arrow buttons +
@@ -201,7 +201,13 @@ export function IntegrationStatusPill({
? "destructive"
: "outline"
}
aria-label={t("settings.integrationPill.ariaLabel")}
// No `aria-label` here. One used to sit on the badge and, being a
// generic string, it REPLACED the accessible name with itself — a screen
// reader announced the same words for "connected · 4 minutes ago" as for
// "error", which is the one distinction the pill exists to make. The
// visible label is the accessible name; the relative time stays
// `aria-hidden` because it is decoration on top of a state that already
// reads.
className={cn(
"max-w-full whitespace-nowrap",
state === "connected" && connectedChipClass,
@@ -12,6 +12,8 @@ import pl from "../../../../../messages/pl.json";
type Payload = {
lastSyncedAt: string | null;
lastSyncTrigger?: "foreground" | "background" | "push" | null;
lastBackgroundSyncAt?: string | null;
syncHealth?: { verdict: string; since: string | null };
metricFreshness?: Array<{
type: string;
@@ -227,3 +229,69 @@ describe("<AppleHealthCard>", () => {
},
);
});
describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
beforeEach(() => {
statusPayload = undefined;
statusLoading = false;
statusError = false;
});
it("names how the last sync arrived and when data last came in on its own", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "background",
lastBackgroundSyncAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
};
const html = render();
expect(html).toContain('data-testid="apple-health-delivery"');
expect(html).toContain("in the background");
expect(html).toContain("Last background sync");
});
it("says plainly that background delivery has never happened", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: "foreground",
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
};
const html = render();
// The reporter's question, answered rather than left to inference: every
// sync so far arrived while the app was open.
expect(html).toContain("with the app open");
expect(html).toContain("every sync so far arrived with the app open");
});
it("reports a missing trigger as unreported instead of guessing one", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
lastSyncTrigger: null,
lastBackgroundSyncAt: null,
syncHealth: { verdict: "fresh", since: null },
};
const html = render();
expect(html).toContain(
'<dd data-testid="apple-health-last-trigger">trigger not reported by the app</dd>',
);
});
it("says there is nothing to report when no sync has ever arrived", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
const html = render();
expect(html).toContain("No sync has arrived yet");
expect(html).not.toContain("Last background sync");
});
});
@@ -18,7 +18,10 @@ import { HeartPulse, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import {
formatRelative,
IntegrationStatusPill,
} from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -31,12 +34,91 @@ import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
type SyncTrigger = "foreground" | "background" | "push";
interface HealthKitStatus {
lastSyncedAt: string | null;
/** What the client said woke it for the most recent accepted batch. */
lastSyncTrigger?: SyncTrigger | null;
/** When data last arrived without the app being opened. */
lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
}
const TRIGGER_KEYS: Record<SyncTrigger, string> = {
foreground: "settings.appleHealth.delivery.trigger.foreground",
background: "settings.appleHealth.delivery.trigger.background",
push: "settings.appleHealth.delivery.trigger.push",
};
/**
* Whether the phone is delivering on its own, or only while someone is looking
* at it. `lastSyncedAt` cannot tell those apart — a card that reports nothing
* but "last sync 10 minutes ago" reads identically in both cases, which is why
* "is background delivery working?" was unanswerable from either side. The
* trigger of the most recent batch and the date of the last background arrival
* are the two facts that settle it, and each is shown only when it exists: a
* client that reports no trigger says so rather than being guessed at.
*/
function DeliveryDiagnostic({
status,
t,
now,
}: {
status: HealthKitStatus;
t: (key: string, params?: Record<string, string | number>) => string;
/** Injectable reference instant, mirroring the pill's own prop. */
now?: Date;
}) {
const reference = now ?? new Date();
const trigger = status.lastSyncTrigger ?? null;
const triggerLabel = t(
trigger
? TRIGGER_KEYS[trigger]
: "settings.appleHealth.delivery.trigger.unknown",
);
const backgroundAt = status.lastBackgroundSyncAt;
return (
<div className="space-y-2" data-testid="apple-health-delivery">
<h3 className="text-sm font-semibold">
{t("settings.appleHealth.delivery.title")}
</h3>
{status.lastSyncedAt ? (
<dl className="space-y-1 text-sm">
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">
{t("settings.appleHealth.delivery.lastTriggerLabel")}
</dt>
<dd data-testid="apple-health-last-trigger">{triggerLabel}</dd>
</div>
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">
{t("settings.appleHealth.delivery.lastBackgroundLabel")}
</dt>
<dd data-testid="apple-health-last-background">
{backgroundAt
? formatRelative(
Math.max(
0,
reference.getTime() - new Date(backgroundAt).getTime(),
),
t,
)
: t("settings.appleHealth.delivery.backgroundNever")}
</dd>
</div>
</dl>
) : (
<p className="text-muted-foreground text-sm">
{t("settings.appleHealth.delivery.noSyncYet")}
</p>
)}
</div>
);
}
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const {
@@ -96,6 +178,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
</p>
)}
{showPill && <DeliveryDiagnostic status={status} t={t} />}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -97,6 +97,25 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
expect(offlineGeoReady()).toBe(false);
});
it("treats a blank GEOLITE2_DIR as unset rather than as the working directory", async () => {
// A compose `${GEOLITE2_DIR:-}` substitutes to the EMPTY STRING, which
// is not nullish. Without the trim, `path.join("", "GeoLite2-City.mmdb")`
// is a RELATIVE path and the readiness check answers from whatever the
// process working directory happens to hold. Proven by putting a stub
// MMDB in the working directory and asserting the blank value still
// resolves to the documented default (which has none).
const originalCwd = process.cwd();
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("stub"));
process.chdir(tmpRoot);
try {
process.env.GEOLITE2_DIR = "";
const { offlineGeoReady } = await import("../geo");
expect(offlineGeoReady()).toBe(false);
} finally {
process.chdir(originalCwd);
}
});
it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => {
writeFileSync(
join(tmpRoot, "GeoLite2-City.mmdb"),
@@ -123,15 +142,60 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
messageKey: "notifications.admin.offlineGeoUnavailableBody",
params: expect.objectContaining({
secretsUrl: expect.stringContaining(
"github.com/MBombeck/HealthLog/settings/secrets/actions",
),
host: "ipwho.is",
docsUrl: expect.stringContaining("docs/self-hosting/geolite2.md"),
}),
metadata: expect.objectContaining({ source: "geo-offline-detection" }),
}),
);
});
it("names the real online provider in the notification params", async () => {
writeFileSync(join(tmpRoot, ".empty"), "");
process.env.IP_GEO_LOOKUP_DISABLED = "1";
process.env.IP_GEO_LOOKUP_URL = "https://ip-api.example/json";
const { lookupIpLocation } = await import("../geo");
await lookupIpLocation("8.8.8.8");
await flushMicrotasks();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ host: "ip-api.example" }),
}),
);
});
it("points the admin at a reachable action, never at a repository secret", async () => {
// issue #659 — the message used to tell a self-hoster to set
// MAXMIND_LICENSE_KEY in this repository's Actions secrets, which is
// not something they can do. Every locale must name the mount instead.
const locales = ["de", "en", "es", "fr", "it", "pl"] as const;
for (const locale of locales) {
const bundle = JSON.parse(
readFileSync(
join(__dirname, `../../../messages/${locale}.json`),
"utf8",
),
) as {
notifications: { admin: { offlineGeoUnavailableBody: string } };
};
const body = bundle.notifications.admin.offlineGeoUnavailableBody;
expect(body, `${locale} still names a build-time secret`).not.toMatch(
/MAXMIND_LICENSE_KEY|GitHub|Actions|secret/i,
);
expect(body, `${locale} does not name the reachable override`).toContain(
"GEOLITE2_DIR",
);
expect(body, `${locale} drops the {host} placeholder`).toContain(
"{host}",
);
expect(body, `${locale} drops the {docsUrl} placeholder`).toContain(
"{docsUrl}",
);
}
});
it("does NOT fire the notification when the City MMDB is present", async () => {
writeFileSync(
join(tmpRoot, "GeoLite2-City.mmdb"),
@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OpenAIClient } from "../openai-client";
import { resetJsonModeDialectCache } from "../json-dialect";
import { singleUserTurn } from "../types";
/**
* The OpenAI-compatible gateway provider (#470), at the wire.
*
* Two properties carry the security weight of this provider and are pinned
* here rather than left to review:
*
* 1. The pinned `admin-key` / `codex` tags keep `requirePublicHost: true`
* no matter what the operator allowlisted. Only the gateway tag — whose
* base URL a person typed — consults the allowlist.
* 2. The SSRF floor under the gateway is `isPublicUrl`, which is the same
* floor the Local provider sits on, including every IPv4 / IPv6
* alt-notation class.
*
* `safeFetch` runs for real here (only the transport underneath it is
* stubbed), so a private-host rejection in these tests is the production
* rejection, not a mock of one.
*/
// 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 interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
function okReply(content = '{"ok":true}') {
return vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content }, finish_reason: "stop" }],
usage: { total_tokens: 7 },
}),
});
}
function errorReply(status: number, body: string) {
return {
ok: false,
status,
text: () => Promise.resolve(body),
};
}
const jsonTurn = () =>
singleUserTurn({
system: "system",
user: "user",
responseFormat: "json",
});
beforeEach(() => {
vi.restoreAllMocks();
resetJsonModeDialectCache();
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("OpenAI-compatible gateway — wire", () => {
it("posts to the configured base URL and reports its own provider tag", async () => {
const fetchMock = okReply();
vi.stubGlobal("fetch", fetchMock);
const client = new OpenAIClient({
apiKey: "gateway-secret",
model: "anthropic/claude-sonnet-4-6",
baseUrl: "https://litellm.example.com/v1",
providerType: "openai-compatible",
});
const result = await client.generateCompletion(jsonTurn());
expect(fetchMock.mock.calls[0][0]).toBe(
"https://litellm.example.com/v1/chat/completions",
);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(
"Bearer gateway-secret",
);
expect(result.providerType).toBe("openai-compatible");
expect(result.model).toBe("anthropic/claude-sonnet-4-6");
});
it("sends no Authorization header at all when the gateway needs no bearer", async () => {
const fetchMock = okReply();
vi.stubGlobal("fetch", fetchMock);
await new OpenAIClient({
apiKey: "",
model: "llama3.1:70b",
baseUrl: "https://gateway.example.com/v1",
providerType: "openai-compatible",
}).generateCompletion(jsonTurn());
expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty(
"Authorization",
);
});
});
describe("OpenAI-compatible gateway — host policy", () => {
it("refuses a private host that the operator has not allowlisted", async () => {
vi.stubGlobal("fetch", okReply());
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
await expect(
new OpenAIClient({
apiKey: "",
model: "m",
baseUrl: "http://192.168.1.5:4000/v1",
providerType: "openai-compatible",
}).generateCompletion(jsonTurn()),
).rejects.toMatchObject({ kind: "private_host" });
});
// The alt-notation classes `isPublicUrl` exists to catch. A gateway is the
// one AI surface whose host a user types, so every one of these has to be
// refused through the gateway arm too — not only through the Local one.
it.each([
["decimal IPv4", "http://2130706433/v1"],
["hex IPv4", "http://0x7f000001/v1"],
["octal IPv4", "http://0177.0.0.1/v1"],
["cloud metadata", "http://169.254.169.254/v1"],
["IPv4-mapped IPv6", "http://[::ffff:169.254.169.254]/v1"],
["IPv6 ULA", "http://[fd00::1]/v1"],
["loopback", "http://127.0.0.1:8080/v1"],
])("refuses %s", async (_label, baseUrl) => {
vi.stubGlobal("fetch", okReply());
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
await expect(
new OpenAIClient({
apiKey: "",
model: "m",
baseUrl,
providerType: "openai-compatible",
}).generateCompletion(jsonTurn()),
).rejects.toMatchObject({ kind: "private_host" });
});
it("reaches a private host the operator allowlisted by name", async () => {
const fetchMock = okReply();
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "litellm.lan");
await new OpenAIClient({
apiKey: "",
model: "m",
baseUrl: "http://litellm.lan:4000/v1",
providerType: "openai-compatible",
}).generateCompletion(jsonTurn());
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("keeps the pinned tags on requirePublicHost even when the operator allowlisted the host", async () => {
vi.stubGlobal("fetch", okReply());
// The operator opened every private host for their local AI endpoint.
// That opt-in belongs to the gateway/local surface; a client carrying an
// OpenAI-issued key must not inherit it.
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
await expect(
new OpenAIClient({
apiKey: "sk-user-openai-key",
model: "gpt-4o",
baseUrl: "http://192.168.1.5:4000/v1",
}).generateCompletion(jsonTurn()),
).rejects.toMatchObject({ kind: "private_host" });
});
});
describe("OpenAI-compatible gateway — JSON-mode dialect", () => {
it("retries once without response_format when the gateway rejects the field", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
errorReply(400, '{"error":"Unknown parameter: response_format"}'),
)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"ok":true}' } }],
usage: { total_tokens: 3 },
}),
});
vi.stubGlobal("fetch", fetchMock);
const result = await new OpenAIClient({
apiKey: "k",
model: "m",
baseUrl: "https://strict-gateway.example.com/v1",
providerType: "openai-compatible",
}).generateCompletion(jsonTurn());
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toHaveProperty(
"response_format",
);
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).not.toHaveProperty(
"response_format",
);
expect(result.content).toBe('{"ok":true}');
});
it("remembers the no-flag dialect for that endpoint", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
errorReply(400, '{"error":"response_format is not supported"}'),
)
.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "{}" } }],
}),
});
vi.stubGlobal("fetch", fetchMock);
const client = new OpenAIClient({
apiKey: "k",
model: "m",
baseUrl: "https://strict-gateway.example.com/v1",
providerType: "openai-compatible",
});
await client.generateCompletion(jsonTurn());
await client.generateCompletion(jsonTurn());
// Three calls total: the rejected probe, its retry, and the second
// generation — which must not probe again.
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(JSON.parse(fetchMock.mock.calls[2][1].body)).not.toHaveProperty(
"response_format",
);
});
it("does not retry an unrelated 4xx", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(errorReply(404, '{"error":"model not found"}'));
vi.stubGlobal("fetch", fetchMock);
await expect(
new OpenAIClient({
apiKey: "k",
model: "nope",
baseUrl: "https://gateway.example.com/v1",
providerType: "openai-compatible",
}).generateCompletion(jsonTurn()),
).rejects.toMatchObject({ httpStatus: 404 });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("never probes or retries on the pinned OpenAI endpoint", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
errorReply(400, '{"error":"Unknown parameter: response_format"}'),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
new OpenAIClient({
apiKey: "sk-key",
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
}).generateCompletion(jsonTurn()),
).rejects.toThrow("OpenAI request failed (400)");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,373 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Resolution for the OpenAI-compatible gateway provider (#470).
*
* The claim this file has to keep true: the gateway resolves against the base
* URL the user saved, and the pinned OpenAI arm cannot see that base URL or
* be redirected by it. Both are asserted at the wire — each test resolves a
* provider and runs a completion through a stubbed transport, so what is
* checked is the request that would actually leave the process.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((v: string) => `decrypted:${v}`),
encrypt: vi.fn((v: string) => `encrypted:${v}`),
}));
vi.mock("@/lib/ai/codex-oauth", () => ({
refreshDeviceTokens: vi.fn(),
encryptCodexCreds: vi.fn(),
decryptCodexCreds: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
import {
AITestConfigError,
resolveProvider,
resolveProviderAvailability,
resolveProviderChain,
resolveProviderForTest,
} from "../provider";
import {
PROVIDER_CHAIN_DEFAULT,
PROVIDER_CHAIN_TYPES,
} from "../provider-chain";
import { singleUserTurn } from "../types";
import { prisma } from "@/lib/db";
/** A user row with every AI column present, overridable per test. */
function userRow(overrides: Record<string, unknown> = {}) {
return {
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: null,
aiCompatKeyEncrypted: null,
aiCompatModel: null,
aiProviderChain: null,
useCentralCodex: false,
codexConnectionStatus: null,
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
...overrides,
} as never;
}
function stubTransport() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"ok":true}' } }],
usage: { total_tokens: 1 },
}),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
const probe = () =>
singleUserTurn({ system: "s", user: "u", responseFormat: "json" });
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
});
describe("the gateway resolves against its own columns", () => {
it("posts to the saved base URL with the saved bearer and model", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiProvider: "OPENAI_COMPATIBLE",
aiCompatBaseUrl: "https://litellm.example.com/v1",
aiCompatKeyEncrypted: "enc-gateway",
aiCompatModel: "anthropic/claude-sonnet-4-6",
}),
);
const provider = await resolveProvider("u-1");
const result = await provider.generateCompletion(probe());
expect(fetchMock.mock.calls[0][0]).toBe(
"https://litellm.example.com/v1/chat/completions",
);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(
"Bearer decrypted:enc-gateway",
);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe(
"anthropic/claude-sonnet-4-6",
);
expect(result.providerType).toBe("openai-compatible");
});
it("works with no bearer at all — a gateway may need none", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiProvider: "OPENAI_COMPATIBLE",
aiCompatBaseUrl: "https://gateway.example.com/v1",
aiCompatModel: "qwen2.5",
}),
);
const provider = await resolveProvider("u-1");
await provider.generateCompletion(probe());
expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty(
"Authorization",
);
});
it("falls back to the shared model when no gateway model is set", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiProvider: "OPENAI_COMPATIBLE",
aiModel: "shared-model",
aiCompatBaseUrl: "https://gateway.example.com/v1",
}),
);
const provider = await resolveProvider("u-1");
await provider.generateCompletion(probe());
expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe(
"shared-model",
);
});
});
describe("the pinned OpenAI arm never reads the gateway's base URL", () => {
it("still posts to api.openai.com when a gateway base URL is saved", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiProvider: "OPENAI",
aiOpenaiKeyEncrypted: "enc-openai",
// A configured gateway sits right there in the row.
aiCompatBaseUrl: "https://attacker.example.com/v1",
aiCompatKeyEncrypted: "enc-gateway",
}),
);
const provider = await resolveProvider("u-1");
await provider.generateCompletion(probe());
expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.openai.com/v1/chat/completions",
);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(
"Bearer decrypted:enc-openai",
);
});
it("resolves the chain's openai entry against api.openai.com regardless of the gateway", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiOpenaiKeyEncrypted: "enc-openai",
aiCompatBaseUrl: "https://attacker.example.com/v1",
aiProviderChain: [
{ providerType: "openai", priority: 1, enabled: true },
],
}),
);
const chain = await resolveProviderChain("u-1");
expect(chain).toHaveLength(1);
await chain[0].instance.generateCompletion(probe());
expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.openai.com/v1/chat/completions",
);
});
it("keeps the test endpoint's OpenAI arm pinned too", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiProvider: "OPENAI",
aiOpenaiKeyEncrypted: "enc-openai",
aiCompatBaseUrl: "https://attacker.example.com/v1",
}),
);
const provider = await resolveProviderForTest("u-1", {});
await provider.generateCompletion(probe());
expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.openai.com/v1/chat/completions",
);
});
it("never sends the OpenAI key to the gateway", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiProvider: "OPENAI_COMPATIBLE",
aiOpenaiKeyEncrypted: "enc-openai",
aiCompatBaseUrl: "https://gateway.example.com/v1",
aiCompatModel: "m",
}),
);
const provider = await resolveProvider("u-1");
await provider.generateCompletion(probe());
const authorization = fetchMock.mock.calls[0][1].headers.Authorization;
expect(authorization).toBeUndefined();
});
});
describe("an unconfigured gateway entry is skipped, not an error", () => {
it("drops the chain entry when no base URL is saved", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiCompatModel: "m",
aiProviderChain: [
{ providerType: "openai-compatible", priority: 1, enabled: true },
],
}),
);
await expect(resolveProviderChain("u-1")).resolves.toEqual([]);
});
it("drops the chain entry when no model can be resolved", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiCompatBaseUrl: "https://gateway.example.com/v1",
aiProviderChain: [
{ providerType: "openai-compatible", priority: 1, enabled: true },
],
}),
);
await expect(resolveProviderChain("u-1")).resolves.toEqual([]);
});
it("resolves the entry once both are present", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiCompatBaseUrl: "https://gateway.example.com/v1",
aiCompatModel: "m",
aiProviderChain: [
{ providerType: "openai-compatible", priority: 1, enabled: true },
],
}),
);
const chain = await resolveProviderChain("u-1");
expect(chain.map((e) => e.providerType)).toEqual(["openai-compatible"]);
});
});
describe("chain vocabulary", () => {
it("offers the gateway as a chain type", () => {
expect(PROVIDER_CHAIN_TYPES).toContain("openai-compatible");
});
it("keeps it out of the default chain — it can only resolve once configured", () => {
expect(
PROVIDER_CHAIN_DEFAULT.map((entry) => entry.providerType),
).not.toContain("openai-compatible");
});
});
describe("availability reporting", () => {
it("reports a configured gateway as the user's own provider", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiCompatBaseUrl: "https://gateway.example.com/v1",
aiCompatModel: "m",
aiProviderChain: [
{ providerType: "openai-compatible", priority: 1, enabled: true },
],
}),
);
await expect(resolveProviderAvailability("u-1")).resolves.toEqual({
aiAvailable: true,
managedBy: "user",
});
});
});
describe("the connection test's gateway arm", () => {
it("refuses a private base URL the operator has not allowlisted", async () => {
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
vi.mocked(prisma.user.findUnique).mockResolvedValue(
userRow({
aiProvider: "OPENAI_COMPATIBLE",
aiCompatBaseUrl: "http://169.254.169.254/v1",
aiCompatModel: "m",
}),
);
await expect(
resolveProviderForTest("u-1", { provider: "OPENAI_COMPATIBLE" }),
).rejects.toBeInstanceOf(AITestConfigError);
vi.unstubAllEnvs();
});
it("refuses an unsaved gateway config with no base URL", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow());
await expect(
resolveProviderForTest("u-1", { provider: "OPENAI_COMPATIBLE" }),
).rejects.toThrow(/base URL/i);
});
it("refuses an unsaved gateway config with no model", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow());
await expect(
resolveProviderForTest("u-1", {
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://gateway.example.com/v1",
}),
).rejects.toThrow(/model/i);
});
it("tests an unsaved gateway config without persisting anything", async () => {
const fetchMock = stubTransport();
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow());
const provider = await resolveProviderForTest("u-1", {
provider: "OPENAI_COMPATIBLE",
compatBaseUrl: "https://unsaved.example.com/v1",
compatKey: "typed-but-unsaved",
compatModel: "typed-model",
});
await provider.generateCompletion(probe());
expect(fetchMock.mock.calls[0][0]).toBe(
"https://unsaved.example.com/v1/chat/completions",
);
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(
"Bearer typed-but-unsaved",
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -55,7 +55,8 @@ export const USER_PLAN_CAP = 2_000_000;
* primary provider is `admin-openai` (the operator's shared API key) OR
* `admin-codex` (the operator's shared ChatGPT-subscription account) — both
* drain the operator's resources, so the operator-cost cap applies. Every other
* primary (`codex` / `openai` / `anthropic` / `local`) is the user's own
* primary (`codex` / `openai` / `anthropic` / `local` / `openai-compatible`)
* is the user's own
* egress, so the generous user-plan cap applies. An empty chain (no provider
* resolved) defaults to the operator cap — the conservative side.
*/
+8 -5
View File
@@ -27,6 +27,8 @@
* The band position is computed HERE, server-side — never left to the model.
*/
import { binaryReferenceSex, type BinaryReferenceSex } from "@/lib/profile/sex";
/** How a band reads against health: the four-way valence the prose leans on. */
export type BandValence = "favourable" | "neutral" | "caution" | "unfavourable";
@@ -54,7 +56,7 @@ export interface MetricInterpretation {
/** Ascending bands when the metric is not sex-split. */
bands?: InterpretationBand[];
/** Sex-split bands; used only when the profile carries the sex. */
sexBands?: Record<"MALE" | "FEMALE", InterpretationBand[]>;
sexBands?: Record<BinaryReferenceSex, InterpretationBand[]>;
/**
* Optional honesty caveat surfaced in the prompt block — used where the KB
* hedges the edges (consumer rating scales, proxy measurements).
@@ -300,7 +302,7 @@ const INTERPRETATION_REGISTRY: Record<string, MetricInterpretation> = {
*/
export function resolveInterpretation(
metricKey: string,
sex: "MALE" | "FEMALE" | null | undefined,
sex: BinaryReferenceSex | null | undefined,
): {
unit: string;
directionOfGood: DirectionOfGood;
@@ -312,8 +314,9 @@ export function resolveInterpretation(
if (!entry) return null;
let bands: InterpretationBand[] | undefined = entry.bands;
if (entry.sexBands) {
if (sex !== "MALE" && sex !== "FEMALE") return null;
bands = entry.sexBands[sex];
const referenceSex = binaryReferenceSex(sex);
if (!referenceSex) return null;
bands = entry.sexBands[referenceSex];
}
if (!bands || bands.length === 0) return null;
return {
@@ -398,7 +401,7 @@ export function classifyBandPosition(
*/
export function interpretationBandEdges(
metricKey: string,
sex?: "MALE" | "FEMALE" | null,
sex?: BinaryReferenceSex | null,
): number[] {
const resolved = resolveInterpretation(metricKey, sex ?? null);
if (!resolved) {
+72
View File
@@ -0,0 +1,72 @@
/**
* Per-endpoint JSON-mode dialect, learned at runtime.
*
* Landed for the Local client in v1.28.28 (#470) and shared with the
* OpenAI-compatible gateway provider in v1.33.1 — both talk to endpoints
* HealthLog does not control, and both need the same answer to the same
* question: does this endpoint accept the standard
* `response_format: { type: "json_object" }` field?
*
* - `"response_format"` (the default): send the standard OpenAI field.
* Strict OpenAI-compatible gateways (LiteLLM, OpenRouter, vLLM) and
* Ollama's own `/v1` shim all accept it.
* - `"none"`: send no structured-output flag at all. Whatever JSON steering
* the caller already put in the prompt is the only steering left.
* (Ollama's NATIVE top-level `format: "json"` never belonged on the
* OpenAI-compatible `/chat/completions` wire — it 400s on strict
* gateways — so the fallback stays minimal rather than resurrecting it.)
*
* The dialect is cached per base URL for the process lifetime: the first
* JSON-mode request that 4xxes with a body referencing `response_format` or
* an unknown parameter flips that endpoint to `"none"` and is retried once
* without the flag. A successful flagged request pins `"response_format"` so
* a later transient 4xx cannot silently degrade the endpoint.
*
* The cache is keyed by base URL, not by provider, so two providers pointed
* at the same gateway learn once between them.
*/
export type JsonModeDialect = "response_format" | "none";
const dialectByBaseUrl = new Map<string, JsonModeDialect>();
/** The learned dialect for an endpoint, defaulting to the standard field. */
export function jsonModeDialectFor(baseUrl: string): JsonModeDialect {
return dialectByBaseUrl.get(baseUrl) ?? "response_format";
}
/** True once an endpoint's dialect has actually been observed. */
export function hasLearnedJsonModeDialect(baseUrl: string): boolean {
return dialectByBaseUrl.has(baseUrl);
}
/** Record what an endpoint answered to. */
export function rememberJsonModeDialect(
baseUrl: string,
dialect: JsonModeDialect,
): void {
dialectByBaseUrl.set(baseUrl, dialect);
}
/** Test hook — clears the learned per-endpoint dialect cache. */
export function resetJsonModeDialectCache(): void {
dialectByBaseUrl.clear();
}
/**
* True when a 4xx error body reads as "this endpoint rejects the
* `response_format` field" — either it names the field outright or it
* complains about an unknown/unexpected parameter.
*/
export function isResponseFormatRejection(
status: number,
bodyExcerpt: string,
): boolean {
if (status < 400 || status >= 500) return false;
return (
/response_format/i.test(bodyExcerpt) ||
/(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test(
bodyExcerpt,
)
);
}
+21 -47
View File
@@ -8,6 +8,14 @@ import type {
CompletionResult,
} from "./types";
import { buildOpenAIMessages, mapFinishReason } from "./openai-wire";
import {
hasLearnedJsonModeDialect,
isResponseFormatRejection,
jsonModeDialectFor,
rememberJsonModeDialect,
resetJsonModeDialectCache,
type JsonModeDialect,
} from "./json-dialect";
interface LocalClientConfig {
apiKey?: string | null;
@@ -20,51 +28,18 @@ const STRICT_JSON_PREFIX =
/**
* v1.28.28 (#470) — per-endpoint JSON-mode dialect, learned at runtime.
*
* - `"response_format"` (the default): send the standard OpenAI
* `response_format: { type: "json_object" }`. Strict OpenAI-compatible
* gateways (LiteLLM, OpenRouter, vLLM) and Ollama's own `/v1` shim all
* accept it.
* - `"none"`: send no structured-output flag at all. The strict-JSON
* instruction prepended to the first user turn remains the only JSON
* steering — which is what this client shipped for years and what local
* model templates respect. (Ollama's NATIVE `format: "json"` field never
* belonged on the OpenAI-compatible `/chat/completions` wire; it was
* tolerated by Ollama but 400s on strict gateways, so the fallback stays
* minimal instead of resurrecting it.)
*
* The dialect is cached per baseUrl for the process lifetime: the first
* JSON-mode request that 4xxes with an error body referencing
* `response_format` / an unknown parameter flips the endpoint to `"none"`
* and is retried once without the flag (self-healing, mirroring the
* google-health dateFilter self-heal posture). Unrelated 4xx errors are
* NOT retried — they surface as the structured error they always were.
* v1.33.1 — the machinery moved to `json-dialect.ts` when the
* OpenAI-compatible gateway provider needed the same learning; the two
* clients share one cache keyed by base URL. The names below stay exported
* from here because this client's tests and call sites were the first
* consumers.
*/
type LocalJsonDialect = "response_format" | "none";
const jsonDialectByBaseUrl = new Map<string, LocalJsonDialect>();
type LocalJsonDialect = JsonModeDialect;
/** Test hook — clears the learned per-endpoint dialect cache. */
export function resetLocalJsonDialectCache(): void {
jsonDialectByBaseUrl.clear();
}
export const resetLocalJsonDialectCache = resetJsonModeDialectCache;
/**
* True when a 4xx error body reads as "this endpoint rejects the
* `response_format` field" — either it names the field outright or it
* complains about an unknown/unexpected parameter.
*/
export function isResponseFormatRejection(
status: number,
bodyExcerpt: string,
): boolean {
if (status < 400 || status >= 500) return false;
return (
/response_format/i.test(bodyExcerpt) ||
/(unknown|unexpected|unrecognized|unsupported|extra)[\s_-]*(parameter|field|argument|property|key)/i.test(
bodyExcerpt,
)
);
}
export { isResponseFormatRejection };
/**
* v1.22 (#89) — absolute backstop for the streaming path. The real ceiling is
@@ -141,9 +116,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
jsonDialect: LocalJsonDialect;
} {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
// v1.28.28 (#470) — per-endpoint JSON dialect (see LocalJsonDialect).
const jsonDialect =
jsonDialectByBaseUrl.get(this.config.baseUrl) ?? "response_format";
// v1.28.28 (#470) — per-endpoint JSON dialect (see json-dialect.ts).
const jsonDialect = jsonModeDialectFor(this.config.baseUrl);
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -255,7 +229,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
jsonDialect === "response_format" &&
isResponseFormatRejection(res.status, bodyExcerpt)
) {
jsonDialectByBaseUrl.set(this.config.baseUrl, "none");
rememberJsonModeDialect(this.config.baseUrl, "none");
annotate({
action: { name: "ai.local.jsonDialect" },
meta: { dialect: "none", httpStatus: res.status },
@@ -279,9 +253,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
if (
params.responseFormat === "json" &&
jsonDialect === "response_format" &&
!jsonDialectByBaseUrl.has(this.config.baseUrl)
!hasLearnedJsonModeDialect(this.config.baseUrl)
) {
jsonDialectByBaseUrl.set(this.config.baseUrl, "response_format");
rememberJsonModeDialect(this.config.baseUrl, "response_format");
annotate({
action: { name: "ai.local.jsonDialect" },
meta: { dialect: "response_format" },
+101 -12
View File
@@ -1,6 +1,14 @@
import { safeFetch } from "@/lib/safe-fetch";
import { annotate } from "@/lib/logging/context";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import { selectOpenAIChatCompletionsCapabilities } from "./openai-capabilities";
import {
hasLearnedJsonModeDialect,
isResponseFormatRejection,
jsonModeDialectFor,
rememberJsonModeDialect,
} from "./json-dialect";
import { requirePublicHostFor } from "./local-host-allowlist";
import {
buildOpenAIMessages,
buildOpenAITools,
@@ -11,6 +19,11 @@ import {
} from "./openai-wire";
interface OpenAIClientConfig {
/**
* Bearer credential. Empty string means "no bearer" — only the
* `openai-compatible` tag ever constructs that way (a LAN gateway with no
* auth); every OpenAI-keyed call site checks the key's presence first.
*/
apiKey: string;
model: string;
baseUrl: string;
@@ -20,11 +33,18 @@ interface OpenAIClientConfig {
* was obtained via the token-exchange grant against a ChatGPT
* subscription — for billing and observability we want that path
* to log as "codex" instead.
*
* v1.33.1 (#470) — `openai-compatible` is the user-configured gateway
* (LiteLLM / OpenRouter / vLLM). It is the ONLY tag whose `baseUrl` is not
* a HealthLog-pinned constant, and the two behaviours below hang off it:
* the operator's private-host allowlist may apply, and the JSON-mode
* dialect is learned per endpoint instead of assumed. Every other tag
* keeps the pinned `api.openai.com` posture unchanged.
*/
providerType?: "admin-key" | "codex";
providerType?: OpenAIProviderType;
}
type OpenAIProviderType = "admin-key" | "codex";
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
export class OpenAIClient implements AIProvider {
readonly type: OpenAIProviderType;
@@ -35,6 +55,15 @@ export class OpenAIClient implements AIProvider {
this.type = config.providerType ?? "admin-key";
}
/**
* True only for the user-configured gateway. Read it as "the base URL came
* from a person, not from this repository" — every behaviour that must not
* change for `api.openai.com` is gated on it.
*/
private get isGateway(): boolean {
return this.type === "openai-compatible";
}
async generateCompletion(
params: CompletionParams,
): Promise<CompletionResult> {
@@ -61,6 +90,14 @@ export class OpenAIClient implements AIProvider {
// therefore stay out of JSON mode, and every tool round is non-JSON by
// construction. Insight/extraction callers opt in with `responseFormat:"json"`.
const useJsonFormat = params.responseFormat === "json" && !hasTools;
// v1.33.1 (#470) — a gateway may reject the standard `response_format`
// field. Learn that per endpoint (shared cache with the Local client) and
// retry once without the flag below. `api.openai.com` defines the field,
// so the pinned tags never consult the dialect and never retry.
const jsonDialect = this.isGateway
? jsonModeDialectFor(this.config.baseUrl)
: "response_format";
const sendJsonFormat = useJsonFormat && jsonDialect === "response_format";
const capabilities = selectOpenAIChatCompletionsCapabilities(
this.config.baseUrl,
this.config.model,
@@ -74,19 +111,27 @@ export class OpenAIClient implements AIProvider {
}
: { [capabilities.tokenBudgetField]: tokenBudget };
// A gateway that needs no bearer (a LAN LiteLLM without a master key)
// gets no `Authorization` header at all rather than an empty one — an
// empty bearer is a 401 on some proxies. Every OpenAI-keyed construction
// site checks the key's presence, so this branch is gateway-only in
// practice.
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const trimmedKey = this.config.apiKey?.trim();
if (trimmedKey) headers.Authorization = `Bearer ${trimmedKey}`;
const res = await safeFetch(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
headers,
body: JSON.stringify({
model: this.config.model,
messages,
...capabilityParams,
...(useJsonFormat
...(sendJsonFormat
? { response_format: { type: "json_object" } }
: {}),
...(tools ? { tools } : {}),
@@ -101,9 +146,16 @@ export class OpenAIClient implements AIProvider {
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
// v1.21.5 — honour the caller's per-request timeout override; default 60 s.
// v1.33.1 (#470) — the gateway tag reuses the Local provider's host
// policy verbatim: public hosts always; a private host only when the
// operator allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS. Every other
// tag stays pinned to `requirePublicHost: true` — `api.openai.com` is
// public and there is nothing to opt into.
{
timeoutMs: params.timeoutMs ?? 60_000,
requirePublicHost: true,
requirePublicHost: this.isGateway
? requirePublicHostFor(this.config.baseUrl)
: true,
signal: params.signal,
},
);
@@ -118,19 +170,54 @@ export class OpenAIClient implements AIProvider {
.slice(0, 500)
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
const err = new Error(`OpenAI request failed (${res.status})`);
// v1.33.1 (#470) — dialect self-heal, gateway only. A 4xx whose body
// names `response_format` (or an unknown parameter) means this endpoint
// rejects the standard JSON flag: learn the no-flag dialect for the base
// URL and retry ONCE without it. The recursion terminates because the
// cached dialect is now "none". Unrelated 4xx/5xx errors are not retried
// and surface as the structured error they always were.
if (
this.isGateway &&
sendJsonFormat &&
isResponseFormatRejection(res.status, bodyExcerpt)
) {
rememberJsonModeDialect(this.config.baseUrl, "none");
annotate({
action: { name: "ai.compat.jsonDialect" },
meta: { dialect: "none", httpStatus: res.status },
});
return this.generateCompletion(params);
}
const err = new Error(
`${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} request failed (${res.status})`,
);
// Keep the body in a structured field rather than the message so
// Error.message stays short and the excerpt lands in dedicated log
// fields (bodyExcerpt) that can be filtered/truncated centrally.
Object.assign(err, {
httpStatus: res.status,
upstream: "openai",
upstream: this.isGateway ? "openai-compatible" : "openai",
model: this.config.model,
bodyExcerpt,
});
throw err;
}
// v1.33.1 (#470) — the standard `response_format` request succeeded: pin
// the dialect for this endpoint so a later transient 4xx can never
// silently degrade it.
if (
this.isGateway &&
sendJsonFormat &&
!hasLearnedJsonModeDialect(this.config.baseUrl)
) {
rememberJsonModeDialect(this.config.baseUrl, "response_format");
annotate({
action: { name: "ai.compat.jsonDialect" },
meta: { dialect: "response_format" },
});
}
const json = (await res.json()) as OpenAIResponseJson;
const choice = json.choices?.[0];
let content = choice?.message?.content;
@@ -163,11 +250,13 @@ export class OpenAIClient implements AIProvider {
// observability. The cascade is unchanged: `isHardProviderFailure`
// already treats `status <= 0` as a hard failure and walks to the next
// provider.
const err = new Error("OpenAI returned empty content");
const err = new Error(
`${this.isGateway ? "OpenAI-compatible gateway" : "OpenAI"} returned empty content`,
);
Object.assign(err, {
httpStatus: 0,
kind: "empty_response",
upstream: "openai",
upstream: this.isGateway ? "openai-compatible" : "openai",
});
throw err;
}
+14
View File
@@ -32,6 +32,15 @@
* (see `resolveProviderChain`), so a persisted `admin-codex` entry never
* resolves on its own.
*
* `openai-compatible` (v1.33.1, #470) is the user-configured gateway —
* LiteLLM, OpenRouter, vLLM, or any other endpoint on the
* `/v1/chat/completions` wire. It is offered in the chain editor but is NOT
* part of `PROVIDER_CHAIN_DEFAULT`: it resolves only against the base URL the
* user saved, so putting it in the default chain would put an entry there
* that can never resolve for anyone who has not configured it. It reads its
* own three columns and never the OpenAI ones — the OpenAI arm's pinned base
* URL stays pinned.
*
* Distinct from `ProviderType` (in `types.ts`) because that enum tags
* the **runtime** provider instance — which has no notion of "admin
* OpenAI" vs "user OpenAI" at the wire level (both are
@@ -43,6 +52,7 @@ export const PROVIDER_CHAIN_TYPES = [
"openai",
"anthropic",
"local",
"openai-compatible",
"admin-openai",
"admin-codex",
] as const;
@@ -65,6 +75,10 @@ export interface ProviderChainEntry {
* 4. local — self-hosted Ollama / LM Studio.
* 5. admin-openai — operator's shared key, last-ditch so a user with
* no personal config still sees insights.
*
* `openai-compatible` is deliberately absent: it resolves only against a
* base URL the user saved, so a default entry would be dead weight for
* everyone who never configures one. Users add it in the chain editor.
*/
export const PROVIDER_CHAIN_DEFAULT: readonly ProviderChainEntry[] = [
{ providerType: "codex", priority: 1, enabled: true },
+155
View File
@@ -36,8 +36,48 @@ type UserAIRow = {
aiAnthropicKeyEncrypted: string | null;
aiLocalKeyEncrypted: string | null;
aiOpenaiKeyEncrypted: string | null;
aiCompatBaseUrl: string | null;
aiCompatKeyEncrypted: string | null;
aiCompatModel: string | null;
};
/**
* v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's
* three dedicated columns. The signature names every column this provider is
* allowed to read: the OpenAI key and `aiBaseUrl` are absent on purpose, so a
* future edit that wants to hand it either has to widen this shape first and
* answer for it.
*
* Returns null when the gateway cannot be addressed — no base URL, or no
* model anywhere. An unconfigured entry is SKIPPED by the chain runner rather
* than erroring, exactly like an `anthropic` entry with no key.
*
* Model resolution: `aiCompatModel` is the override; absent, the shared
* `aiModel` applies (a gateway usually routes the same model name the user
* already types for their other providers). Nothing is invented — with
* neither set there is no model to send, so the entry does not resolve.
*
* The key is OPTIONAL: a LAN LiteLLM without a master key needs no bearer,
* and the client omits the header entirely for an empty string. The base URL
* passed the SSRF floor at write time (`/api/user/ai-provider`); the client
* re-applies the Local provider's host policy at call time.
*/
function buildCompatProvider(row: {
aiModel: string | null;
aiCompatBaseUrl: string | null;
aiCompatKeyEncrypted: string | null;
aiCompatModel: string | null;
}): AIProvider | null {
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
if (!row.aiCompatBaseUrl || !model) return null;
return new OpenAIClient({
apiKey: row.aiCompatKeyEncrypted ? decrypt(row.aiCompatKeyEncrypted) : "",
model,
baseUrl: row.aiCompatBaseUrl,
providerType: "openai-compatible",
});
}
/**
* Build a provider from a user-level config row. Returns null if the row does
* not select a usable per-user provider (caller falls back to admin/codex).
@@ -68,6 +108,11 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
baseUrl: row.aiBaseUrl,
});
}
case "OPENAI_COMPATIBLE": {
// v1.33.1 (#470) — the gateway provider. Reads its own three columns
// and nothing else; the OpenAI arm below stays pinned.
return buildCompatProvider(row);
}
case "OPENAI": {
// v1.4.3: user-level OpenAI key gets first crack — only fall back
// to the admin key if the user hasn't supplied their own. The
@@ -364,6 +409,9 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiCompatBaseUrl: true,
aiCompatKeyEncrypted: true,
aiCompatModel: true,
},
});
@@ -420,6 +468,9 @@ export async function resolveProviderChain(
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiCompatBaseUrl: true,
aiCompatKeyEncrypted: true,
aiCompatModel: true,
aiProviderChain: true,
useCentralCodex: true,
},
@@ -468,6 +519,9 @@ export interface ProviderCredentialRow {
aiLocalKeyEncrypted: string | null;
aiOpenaiKeyEncrypted: string | null;
aiBaseUrl: string | null;
aiModel: string | null;
aiCompatBaseUrl: string | null;
aiCompatModel: string | null;
codexConnectionStatus: string | null;
codexAccessTokenEncrypted: string | null;
codexRefreshTokenEncrypted: string | null;
@@ -514,6 +568,13 @@ export function userRowHasProviderCredential(
case "local":
if (row.aiBaseUrl) return true;
break;
case "openai-compatible":
// Presence mirrors `buildCompatProvider`: a gateway needs an address
// AND a model name; the key is optional.
if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) {
return true;
}
break;
case "admin-openai":
if (adminKeyConfigured) return true;
break;
@@ -525,6 +586,13 @@ export function userRowHasProviderCredential(
const choice = row.aiProvider?.toUpperCase();
if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return true;
if (choice === "LOCAL" && row.aiBaseUrl) return true;
if (
choice === "OPENAI_COMPATIBLE" &&
row.aiCompatBaseUrl &&
(row.aiCompatModel || row.aiModel)
) {
return true;
}
if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return true;
if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return true;
return adminKeyConfigured;
@@ -549,6 +617,9 @@ export async function hasAnyConfiguredProvider(
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiBaseUrl: true,
aiCompatBaseUrl: true,
aiCompatModel: true,
aiModel: true,
codexConnectionStatus: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
@@ -637,6 +708,14 @@ function resolveManagedByFromRow(
case "local":
if (row.aiBaseUrl) return "local";
break;
case "openai-compatible":
// The gateway is the user's own egress to an endpoint they chose,
// like a BYO key — not the operator's, and not presumed on-host
// (LiteLLM / OpenRouter are usually remote).
if (row.aiCompatBaseUrl && (row.aiCompatModel || row.aiModel)) {
return "user";
}
break;
case "admin-openai":
if (adminKeyConfigured) return "server";
break;
@@ -648,6 +727,13 @@ function resolveManagedByFromRow(
const choice = row.aiProvider?.toUpperCase();
if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return "user";
if (choice === "LOCAL" && row.aiBaseUrl) return "local";
if (
choice === "OPENAI_COMPATIBLE" &&
row.aiCompatBaseUrl &&
(row.aiCompatModel || row.aiModel)
) {
return "user";
}
if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return "user";
if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return "user";
return adminKeyConfigured ? "server" : null;
@@ -672,6 +758,9 @@ export async function resolveProviderAvailability(
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiBaseUrl: true,
aiCompatBaseUrl: true,
aiCompatModel: true,
aiModel: true,
codexConnectionStatus: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
@@ -715,6 +804,9 @@ async function resolveProviderForType(
aiOpenaiKeyEncrypted: string | null;
aiBaseUrl: string | null;
aiModel: string | null;
aiCompatBaseUrl: string | null;
aiCompatKeyEncrypted: string | null;
aiCompatModel: string | null;
} | null;
},
): Promise<AIProvider | null> {
@@ -749,6 +841,13 @@ async function resolveProviderForType(
baseUrl: ctx.userRow.aiBaseUrl,
});
}
case "openai-compatible": {
// v1.33.1 (#470) — the user's gateway. Note what is NOT read here:
// `aiOpenaiKeyEncrypted` and `aiBaseUrl`. The gateway gets the
// credential the user issued for it, or none at all.
if (!ctx.userRow) return null;
return buildCompatProvider(ctx.userRow);
}
case "admin-openai": {
const admin = await resolveAdminProvider();
return admin.type === "none" ? null : admin;
@@ -776,6 +875,13 @@ export type AITestOverride = {
anthropicKey?: string | null;
localKey?: string | null;
openaiKey?: string | null;
// v1.33.1 (#470) — the gateway's own fields. Dedicated rather than shared
// with `baseUrl` / `openaiKey` for the same reason the columns are: the
// pinned OpenAI arm must not be able to see a user-supplied host, and the
// gateway must not be able to see the OpenAI key.
compatBaseUrl?: string | null;
compatKey?: string | null;
compatModel?: string | null;
};
export class AITestConfigError extends Error {
@@ -806,6 +912,9 @@ export async function resolveProviderForTest(
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
aiCompatBaseUrl: true,
aiCompatKeyEncrypted: true,
aiCompatModel: true,
},
});
@@ -877,6 +986,52 @@ export async function resolveProviderForTest(
baseUrl,
});
}
case "OPENAI_COMPATIBLE": {
// v1.33.1 (#470) — the gateway. Reads `aiCompatBaseUrl`, never the
// merged `baseUrl` above (which can carry a stale LOCAL URL) and never
// the OpenAI key. Same host policy as LOCAL: public always, private
// only when the operator allowlisted it.
const compatBaseUrl = (
override.compatBaseUrl ??
stored?.aiCompatBaseUrl ??
""
)
.toString()
.trim();
if (!compatBaseUrl) {
throw new AITestConfigError(
422,
"OpenAI-compatible provider requires a base URL",
);
}
if (!isLocalAiHostAllowed(compatBaseUrl) && !isPublicUrl(compatBaseUrl)) {
throw new AITestConfigError(
422,
"Base URL points to an internal/private host",
);
}
const compatModel =
(override.compatModel ?? stored?.aiCompatModel ?? "")
.toString()
.trim() || model;
if (!compatModel) {
throw new AITestConfigError(
422,
"OpenAI-compatible provider requires a model name",
);
}
const apiKey =
override.compatKey?.trim() ||
(stored?.aiCompatKeyEncrypted
? decrypt(stored.aiCompatKeyEncrypted)
: "");
return new OpenAIClient({
apiKey,
model: compatModel,
baseUrl: compatBaseUrl,
providerType: "openai-compatible",
});
}
case "CHATGPT_OAUTH": {
const codex = await resolveCodexProvider(userId);
if (codex) return codex;
+6 -1
View File
@@ -102,8 +102,13 @@ export type InsightResult = z.infer<typeof insightResultSchema>;
// ─── Provider Types ────────────────────────────────────────
// v1.33.1 (#470) — `openai-compatible` is the user-configured gateway
// (LiteLLM / OpenRouter / vLLM). It runs the OpenAI client against a base URL
// the user owns, so it is a distinct runtime tag from `admin-key`: logs,
// analytics and the budget classifier must be able to tell "the operator's
// OpenAI key" from "an endpoint someone pointed us at".
export type ProviderType =
"codex" | "admin-key" | "anthropic" | "local" | "none";
"codex" | "admin-key" | "openai-compatible" | "anthropic" | "local" | "none";
export interface AIProvider {
type: ProviderType;
+7
View File
@@ -114,6 +114,7 @@ export type VisionProviderType =
| "admin-openai"
| "admin-key"
| "local"
| "openai-compatible"
| "codex"
| "admin-codex"
| "none";
@@ -127,6 +128,11 @@ export type VisionProviderType =
* configuring a self-hosted model and we cannot sniff
* its capabilities; a non-vision local model surfaces
* a clear extract failure rather than a silent gate.
* - openai-compatible → same trust-by-default: a gateway routes whatever
* model name its operator aliased, so the OpenAI
* allowlist would reject perfectly capable models
* (and accept incapable ones) on the strength of a
* string the gateway owner picked.
* - codex → vision when the resolved codex slug is a
* multimodal model (GPT-5.x / gpt-4o-class). Image
* input rides the ChatGPT plan — no API key. A
@@ -145,6 +151,7 @@ export function supportsVisionForConfig(
case "admin-key":
return model ? openaiSupportsVision(model) : false;
case "local":
case "openai-compatible":
return true;
case "codex":
// The operator-shared central Codex runs the same `codex/responses` wire on
@@ -90,6 +90,29 @@ describe("getEffectiveRange", () => {
});
});
// The resolver narrows the stored three-value sex to the two a sex-split
// reference table has rows for, and OTHER / "no answer" must land on the
// neutral band rather than on either side. Nothing pinned that before, so a
// resolver that quietly stopped reading the profile's sex — or read it and
// picked a default side — went unnoticed.
describe("sex-split defaults", () => {
const withGender = (gender: string | null) =>
getEffectiveRange("BODY_FAT", { ...baseProfile, gender }, null).range!;
it("gives each recorded side its own body-fat band", () => {
expect(withGender("MALE").greenMin).toBe(14);
expect(withGender("MALE").greenMax).toBe(24);
expect(withGender("FEMALE").greenMin).toBe(21);
expect(withGender("FEMALE").greenMax).toBe(31);
});
it("gives OTHER and an unanswered profile the same neutral band", () => {
const neutral = { greenMin: 17, greenMax: 27 };
expect(withGender("OTHER")).toMatchObject(neutral);
expect(withGender(null)).toMatchObject(neutral);
});
});
describe("getAllEffectiveRanges", () => {
it("returns an entry for every metric", () => {
const ranges = getAllEffectiveRanges(baseProfile, null);
+2 -6
View File
@@ -15,6 +15,7 @@
* consistently.
*/
import { binaryReferenceSex } from "@/lib/profile/sex";
import { getBpTargets } from "./bp-targets";
import {
getAgeFromDateOfBirth,
@@ -120,11 +121,6 @@ const GLUCOSE_DEFAULTS: Record<
BEDTIME: { min: 90, max: 150 },
};
function normalizedGender(value: string | null): "MALE" | "FEMALE" | null {
if (value === "MALE" || value === "FEMALE") return value;
return null;
}
function toDate(value: Date | string | null): Date | null {
if (!value) return null;
return value instanceof Date ? value : new Date(value);
@@ -135,7 +131,7 @@ function defaultRange(
profile: UserProfileForRange,
): TrafficRange | null {
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
const gender = normalizedGender(profile.gender ?? null);
const gender = binaryReferenceSex(profile.gender);
const dateOfBirth = toDate(profile.dateOfBirth);
switch (metric) {
@@ -16,6 +16,9 @@ import { bucketTimeSeries } from "../bucket-time-series";
import { makeBucketLabelFormatters } from "../bucket-label";
import { makeFormatters } from "@/lib/format-locale";
// Berlin was the module's hard-coded calendar, so these expectations were
// always Berlin's. Naming it here keeps that visible now it is a parameter.
const BERLIN = "Europe/Berlin";
describe("makeBucketLabelFormatters (#490)", () => {
// Two July instants (Berlin summer) forced into a month bucket.
const monthBucket = bucketTimeSeries(
@@ -23,7 +26,7 @@ describe("makeBucketLabelFormatters (#490)", () => {
{ timestamp: new Date("2026-07-05T10:00:00Z"), values: { WEIGHT: 80 } },
{ timestamp: new Date("2026-07-20T10:00:00Z"), values: { WEIGHT: 82 } },
],
{ bucket: "month" },
{ bucket: "month", timeZone: BERLIN },
);
it("labels a Berlin July month bucket as July for every profile", () => {
@@ -65,7 +68,7 @@ describe("makeBucketLabelFormatters (#490)", () => {
values: { WEIGHT: 80 },
},
],
{ bucket: "week" },
{ bucket: "week", timeZone: BERLIN },
);
const ts = weekBucket.points[0].timestamp;
expect(ts).toBe(Date.UTC(2026, 6, 13));
@@ -30,12 +30,73 @@ describe("pickBucket()", () => {
});
});
// These expectations were always Berlin's — the module used to hard-code it,
// so the tests read as timezone-agnostic while quietly depending on one. Now
// that the boundary calendar is a parameter, the assumption is written down
// rather than implied.
const BERLIN = "Europe/Berlin";
describe("the boundary calendar is the caller's, not Berlin's", () => {
// 2025-03-31T16:00Z is 18:00 on 31 March in Berlin (UTC+2, summer time is
// already in force) and 01:00 on 1 April in Tokyo (UTC+9). Which month bar
// this reading lands in is decided by the calendar the caller names, and
// until now the caller could not name one: every chart bucketed everyone's
// readings into Berlin's months.
const lateEvening = new Date("2025-03-31T16:00:00Z");
const points = [{ timestamp: lateEvening, values: { v: 1 } }];
it("puts a late-evening reading in March for Berlin", () => {
const out = bucketTimeSeries(points, {
bucket: "month",
timeZone: "Europe/Berlin",
});
expect(new Date(out.points[0].timestamp).toISOString()).toBe(
"2025-03-01T00:00:00.000Z",
);
});
it("puts the same instant in April for Tokyo", () => {
const out = bucketTimeSeries(points, {
bucket: "month",
timeZone: "Asia/Tokyo",
});
expect(new Date(out.points[0].timestamp).toISOString()).toBe(
"2025-04-01T00:00:00.000Z",
);
});
it("moves the ISO week too, not only the month", () => {
// A separate instant, because the month fixture above does not serve
// here: 31 March 2025 is a Monday in Berlin and 1 April a Tuesday in
// Tokyo, which is the SAME ISO week. The week boundary only moves when
// one side is still Sunday while the other is already Monday.
// 2025-03-30T16:00Z is Sunday 18:00 in Berlin and Monday 01:00 in Tokyo.
const sundayEvening = [
{ timestamp: new Date("2025-03-30T16:00:00Z"), values: { v: 1 } },
];
const berlin = bucketTimeSeries(sundayEvening, {
bucket: "week",
timeZone: "Europe/Berlin",
});
const tokyo = bucketTimeSeries(sundayEvening, {
bucket: "week",
timeZone: "Asia/Tokyo",
});
expect(new Date(berlin.points[0].timestamp).toISOString()).toBe(
"2025-03-24T00:00:00.000Z",
);
expect(new Date(tokyo.points[0].timestamp).toISOString()).toBe(
"2025-03-31T00:00:00.000Z",
);
});
});
describe("bucketTimeSeries()", () => {
const start = new Date("2025-01-06T00:00:00Z"); // Monday
it("preserves daily granularity for ranges <= 90 days", () => {
const points = [0, 1, 2, 30, 89].map((d) => dailyAt(start, d, 80 + d));
const result = bucketTimeSeries(points);
const result = bucketTimeSeries(points, { timeZone: BERLIN });
expect(result.bucket).toBe("day");
expect(result.points).toHaveLength(5);
});
@@ -45,7 +106,7 @@ describe("bucketTimeSeries()", () => {
const points = Array.from({ length: 200 }, (_, i) =>
dailyAt(start, i, 80 + (i % 5)),
);
const result = bucketTimeSeries(points);
const result = bucketTimeSeries(points, { timeZone: BERLIN });
expect(result.bucket).toBe("week");
// 200 / 7 ≈ 28.5 — first/last weeks may be partial → 29 distinct keys.
expect(result.points.length).toBeGreaterThanOrEqual(28);
@@ -61,7 +122,7 @@ describe("bucketTimeSeries()", () => {
const points = Array.from({ length: 800 }, (_, i) =>
dailyAt(start, i, 100),
);
const result = bucketTimeSeries(points);
const result = bucketTimeSeries(points, { timeZone: BERLIN });
expect(result.bucket).toBe("month");
// ~26 calendar months
expect(result.points.length).toBeGreaterThanOrEqual(25);
@@ -79,7 +140,10 @@ describe("bucketTimeSeries()", () => {
{ timestamp: start.getTime(), values: { weight: 80 } },
{ timestamp: start.getTime() + 200 * dayMs, values: { weight: 90 } },
];
const result = bucketTimeSeries(points, { bucket: "week" });
const result = bucketTimeSeries(points, {
bucket: "week",
timeZone: BERLIN,
});
expect(result.points).toHaveLength(2);
expect(result.points.map((p) => p.values.weight)).toEqual([80, 90]);
});
@@ -90,7 +154,10 @@ describe("bucketTimeSeries()", () => {
{ timestamp: start.getTime() + dayMs, values: { sys: 140 } }, // dia missing
{ timestamp: start.getTime() + 2 * dayMs, values: { dia: 78 } }, // sys missing
];
const result = bucketTimeSeries(points, { bucket: "week" });
const result = bucketTimeSeries(points, {
bucket: "week",
timeZone: BERLIN,
});
expect(result.points).toHaveLength(1);
const point = result.points[0];
expect(point.counts.sys).toBe(2);
@@ -101,7 +168,10 @@ describe("bucketTimeSeries()", () => {
it("respects the explicit bucket override", () => {
const points = [0, 1, 2].map((d) => dailyAt(start, d, 80));
const result = bucketTimeSeries(points, { bucket: "month" });
const result = bucketTimeSeries(points, {
bucket: "month",
timeZone: BERLIN,
});
expect(result.bucket).toBe("month");
expect(result.points).toHaveLength(1);
});
+52 -27
View File
@@ -35,35 +35,45 @@ export interface BucketedChartSeries {
points: ChartBucketPoint[];
}
const BERLIN_TZ = "Europe/Berlin";
const BERLIN_FMT = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const MS_PER_DAY = 24 * 60 * 60 * 1000;
interface BerlinYmd {
/**
* One formatter per timezone. Building an `Intl.DateTimeFormat` is expensive
* enough that doing it per point matters on a two-year monthly chart.
*/
const ymdFormatters = new Map<string, Intl.DateTimeFormat>();
function ymdFormatter(timeZone: string): Intl.DateTimeFormat {
const cached = ymdFormatters.get(timeZone);
if (cached) return cached;
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
ymdFormatters.set(timeZone, fmt);
return fmt;
}
interface ZonedYmd {
year: number;
month: number;
day: number;
}
function toBerlinYmd(date: Date): BerlinYmd {
const parts = BERLIN_FMT.formatToParts(date);
function toZonedYmd(date: Date, timeZone: string): ZonedYmd {
const parts = ymdFormatter(timeZone).formatToParts(date);
const year = Number(parts.find((p) => p.type === "year")?.value);
const month = Number(parts.find((p) => p.type === "month")?.value);
const day = Number(parts.find((p) => p.type === "day")?.value);
if (!year || !month || !day) {
throw new Error("Could not derive Berlin Y-M-D");
throw new Error(`Could not derive Y-M-D in ${timeZone}`);
}
return { year, month, day };
}
function utcMidnight({ year, month, day }: BerlinYmd): number {
function utcMidnight({ year, month, day }: ZonedYmd): number {
return Date.UTC(year, month - 1, day);
}
@@ -72,21 +82,21 @@ function utcMidnight({ year, month, day }: BerlinYmd): number {
* containing the first Thursday of that year. Returns the UTC
* midnight of the Monday of the week containing `date`.
*/
function isoWeekStartUtc(date: Date): number {
const ymd = toBerlinYmd(date);
function isoWeekStartUtc(date: Date, timeZone: string): number {
const ymd = toZonedYmd(date, timeZone);
const utc = utcMidnight(ymd);
// Day of week, 1=Mon..7=Sun (ISO).
const dow = ((new Date(utc).getUTCDay() + 6) % 7) + 1;
return utc - (dow - 1) * MS_PER_DAY;
}
function monthStartUtc(date: Date): number {
const { year, month } = toBerlinYmd(date);
function monthStartUtc(date: Date, timeZone: string): number {
const { year, month } = toZonedYmd(date, timeZone);
return Date.UTC(year, month - 1, 1);
}
function dayStartUtc(date: Date): number {
return utcMidnight(toBerlinYmd(date));
function dayStartUtc(date: Date, timeZone: string): number {
return utcMidnight(toZonedYmd(date, timeZone));
}
export interface BucketInputPoint {
@@ -103,6 +113,16 @@ export interface BucketInputPoint {
export interface BucketTimeSeriesOptions {
/** Override the bucketer when the caller already knows it. */
bucket?: ChartBucketType;
/**
* The calendar the day / week / month boundaries are drawn in.
*
* Deliberately required rather than defaulted. This module used to hard-code
* Europe/Berlin, which meant a reading late on the last evening of a month
* fell into the wrong month's bar for anyone who does not live there — and a
* default would have left every caller that forgets to pass one still wrong,
* silently. Required turns that into a compile error instead.
*/
timeZone: string;
}
/**
@@ -116,14 +136,15 @@ export function pickBucket(rangeDays: number): ChartBucketType {
/**
* Bucket a daily-aggregated chart series into day/week/month groups.
* Weeks use ISO 8601 (Monday start); months use Berlin calendar.
* Empty buckets are skipped so a sparse user does not get a flat zero
* baseline.
* Weeks use ISO 8601 (Monday start); every boundary is drawn in the caller's
* `timeZone`. Empty buckets are skipped so a sparse user does not get a flat
* zero baseline.
*/
export function bucketTimeSeries(
points: BucketInputPoint[],
options: BucketTimeSeriesOptions = {},
options: BucketTimeSeriesOptions,
): BucketedChartSeries {
const { timeZone } = options;
const sorted = [...points].sort(
(a, b) => toMs(a.timestamp) - toMs(b.timestamp),
);
@@ -144,14 +165,18 @@ export function bucketTimeSeries(
// Day-granularity is the upstream's native shape, but we still go
// through aggregation so the output shape is consistent and so
// duplicate-day callers (raw API mode) collapse cleanly.
return aggregate(sorted, bucket, (ts) => dayStartUtc(new Date(toMs(ts))));
return aggregate(sorted, bucket, (ts) =>
dayStartUtc(new Date(toMs(ts)), timeZone),
);
}
if (bucket === "week") {
return aggregate(sorted, bucket, (ts) =>
isoWeekStartUtc(new Date(toMs(ts))),
isoWeekStartUtc(new Date(toMs(ts)), timeZone),
);
}
return aggregate(sorted, bucket, (ts) => monthStartUtc(new Date(toMs(ts))));
return aggregate(sorted, bucket, (ts) =>
monthStartUtc(new Date(toMs(ts)), timeZone),
);
}
function toMs(ts: number | Date): number {
+35 -2
View File
@@ -35,6 +35,9 @@ import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import type { ReportSelection } from "@/lib/report-selection/selection";
import { prisma } from "@/lib/db";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import type { TimeFormatPreference } from "@/generated/prisma/client";
/**
* 20 per hour per link. A practice saves the record once, maybe twice; a
@@ -53,7 +56,23 @@ function notFound() {
}
export type ReportDownloadResult =
| { ok: true; report: DoctorReportData; selection: ReportSelection }
| {
ok: true;
report: DoctorReportData;
selection: ReportSelection;
/**
* The OWNER's timezone and clock preference, not the reader's.
*
* A reading was recorded at a moment in the owner's life, and the date
* printed beside it has to be the date it was that day where they were.
* The page at `c/[token]` already resolves this; the downloads did not,
* so the same reading could carry one date on screen and another in the
* PDF a practice files. Resolved here rather than per route so the two
* download formats cannot drift apart the way the page and the PDF did.
*/
ownerTz: string;
ownerTimeFormat: TimeFormatPreference;
}
| { ok: false; response: Response };
/**
@@ -128,5 +147,19 @@ export async function resolveShareReportDownload(
meta: { format, leafCount: view.selection.leaves.length },
});
return { ok: true, report: view.report, selection: view.selection };
const [ownerTz, ownerRow] = await Promise.all([
resolveUserTimezone(context.ownerUserId),
prisma.user.findUnique({
where: { id: context.ownerUserId },
select: { timeFormat: true },
}),
]);
return {
ok: true,
report: view.report,
selection: view.selection,
ownerTz,
ownerTimeFormat: ownerRow?.timeFormat ?? "AUTO",
};
}
+4
View File
@@ -81,6 +81,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" },
{ model: "User", field: "aiLocalKeyEncrypted", kind: "string" },
{ model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" },
// v1.33.1 (#470) — the OpenAI-compatible gateway provider's optional bearer.
// A dedicated column, never the OpenAI key: the gateway host is user-chosen,
// so the credential that reaches it must be one the user issued for it.
{ model: "User", field: "aiCompatKeyEncrypted", kind: "string" },
{ model: "User", field: "insuranceNumberEncrypted", kind: "string" },
// v1.23 — TOTP shared secret (second factor). String ciphertext like the
// other User credential columns.
+78
View File
@@ -26,6 +26,19 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
updatedAt,
}),
},
cycleSymptom: {
findMany: vi.fn().mockResolvedValue([
{
id: "sym-custom",
key: "custom_ache",
labelKey: "cycle.symptom.custom_ache",
categoryId: "cat-1",
icon: "Activity",
sortOrder: 3,
isActive: true,
},
]),
},
menstrualCycle: {
findMany: vi.fn().mockResolvedValue([
{
@@ -151,3 +164,68 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
});
});
});
describe("a symptom the account created survives the round trip", () => {
/**
* The defect this pins: the seeded symptom catalogue is reference data every
* instance already has, but a symptom the user made exists only in their
* account. It was never carried, so on restore its key resolved to nothing —
* and the link was silently filtered out. The day-log came back, one of its
* symptoms did not, and the restore reported success.
*/
function client(customSymptoms: unknown[]) {
return {
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
cycleSymptom: { findMany: vi.fn().mockResolvedValue(customSymptoms) },
} as never;
}
it("carries the account's own symptom definitions", async () => {
const section = await buildCycleBackupSection(
client([
{
id: "sym-1",
key: "custom_ache",
labelKey: "cycle.symptom.custom_ache",
categoryId: "cat-1",
icon: null,
sortOrder: 2,
isActive: true,
},
]),
"user-1",
{ purpose: "disaster-recovery" },
);
expect(section.customSymptoms).toHaveLength(1);
expect(section.customSymptoms[0]).toMatchObject({
key: "custom_ache",
labelKey: "cycle.symptom.custom_ache",
categoryId: "cat-1",
sortOrder: 2,
isActive: true,
});
});
it("asks only for the account's own rows, never the seeded catalogue", async () => {
// Carrying the seeded rows would let one instance's restore rewrite
// another instance's reference data.
const c = client([]);
await buildCycleBackupSection(c, "user-1", {});
expect(
(c as unknown as { cycleSymptom: { findMany: ReturnType<typeof vi.fn> } })
.cycleSymptom.findMany,
).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
});
it("writes an empty list rather than omitting the section", async () => {
// An absent key and an empty list read the same to a careless consumer.
// The restore distinguishes them, so the builder must be explicit.
const section = await buildCycleBackupSection(client([]), "user-1", {});
expect(section.customSymptoms).toEqual([]);
});
});
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more