diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml
index 2a0d092b1..3b5721a18 100644
--- a/.github/workflows/dependabot-auto-merge.yml
+++ b/.github/workflows/dependabot-auto-merge.yml
@@ -12,11 +12,11 @@ jobs:
if: github.actor == 'dependabot[bot]'
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- - uses: pnpm/action-setup@v6
+ - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
@@ -27,7 +27,34 @@ jobs:
- run: pnpm test
- - run: gh pr merge --auto --squash "$PR_URL"
+ # Auto-merge is gated on the Dependabot-declared update type:
+ # patch and minor bumps merge once required checks pass; major
+ # bumps get a comment and wait for the maintainer. A major bump
+ # routinely carries breaking changes the unit suite alone cannot
+ # vouch for.
+ - name: Fetch Dependabot metadata
+ id: metadata
+ uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Enable auto-merge (patch / minor only)
+ if: >-
+ steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
+ steps.metadata.outputs.update-type == 'version-update:semver-minor'
+ run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Flag major update for manual review
+ if: steps.metadata.outputs.update-type == 'version-update:semver-major'
+ run: >-
+ gh pr comment "$PR_URL" --body
+ "Major version bump (${DEP_NAMES}) — auto-merge is intentionally
+ disabled for semver-major updates. Review the upstream changelog
+ and merge manually."
+ env:
+ PR_URL: ${{ github.event.pull_request.html_url }}
+ DEP_NAMES: ${{ steps.metadata.outputs.dependency-names }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 0d32ac4c7..7b8585d2b 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -20,6 +20,11 @@ on:
branches: [main]
workflow_dispatch:
+# Default for any job that doesn't declare its own block; both jobs
+# below elevate to packages:write explicitly.
+permissions:
+ contents: read
+
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -72,10 +77,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# GHCR rejects repository refs with uppercase letters. `IMAGE_NAME`
# is derived from `github.repository`, which preserves the case of
@@ -93,7 +98,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -101,7 +106,7 @@ jobs:
- name: Compute image labels
id: meta
- uses: docker/metadata-action@v6
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.IMAGE_REF }}
labels: |
@@ -181,6 +186,19 @@ jobs:
asn_size="$(stat -c%s assets/geolite2/GeoLite2-ASN.mmdb)"
echo "::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb ${city_size} bytes; GeoLite2-ASN.mmdb ${asn_size} bytes."
+ # Build provenance for /api/version: the short commit SHA and an
+ # ISO-8601 build timestamp travel into the image as build-args
+ # (NEXT_PUBLIC_APP_BUILD_SHA / NEXT_PUBLIC_APP_BUILT_AT) so the
+ # deploy runbook can verify which commit a running container
+ # actually carries — the `:latest` pull is the source of truth,
+ # not the queued deploy status. 12 hex chars is plenty to be
+ # unambiguous in this repository.
+ - name: Resolve build provenance
+ id: provenance
+ run: |
+ echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
+ echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
+
# Per-ref + per-platform gha cache scope. Tag builds warm-start
# off main's last good layer set for the same platform; tag and
# main builds at the same SHA do not contend on writes, and
@@ -188,7 +206,7 @@ jobs:
# arm64 layer never poisons an amd64 build).
- name: Build & push by digest
id: build
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
@@ -203,6 +221,8 @@ jobs:
# can never survive a cache hit into the new image.
build-args: |
NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }}
+ NEXT_PUBLIC_APP_BUILD_SHA=${{ steps.provenance.outputs.build_sha }}
+ NEXT_PUBLIC_APP_BUILT_AT=${{ steps.provenance.outputs.built_at }}
outputs: |
type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: |
@@ -224,7 +244,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name != 'pull_request'
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.cache_scope }}
path: /tmp/digests/*
@@ -251,14 +271,14 @@ jobs:
steps:
- name: Download digest artifacts
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# GHCR requires the repository ref to be lowercase. See the same
# step in the `build` job above for the longer rationale.
@@ -267,7 +287,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -275,7 +295,7 @@ jobs:
- name: Compute image tags & labels
id: meta
- uses: docker/metadata-action@v6
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.IMAGE_REF }}
tags: |
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 739e6c79b..854ad38e0 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -6,6 +6,9 @@ on:
push:
branches: [main]
+permissions:
+ contents: read
+
# Cancel in-progress runs when a new commit lands on the same PR.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
@@ -41,11 +44,11 @@ jobs:
NODE_ENV: production
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- - uses: pnpm/action-setup@v6
+ - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
@@ -57,7 +60,7 @@ jobs:
- run: pnpm db:migrate:deploy
- name: Cache Playwright browsers
- uses: actions/cache@v5
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -74,7 +77,7 @@ jobs:
- name: Upload Playwright HTML report on failure
if: failure()
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: playwright-report/
diff --git a/.github/workflows/env-check.yml b/.github/workflows/env-check.yml
index 460140dd2..9c99ced3f 100644
--- a/.github/workflows/env-check.yml
+++ b/.github/workflows/env-check.yml
@@ -25,13 +25,13 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install pnpm
- uses: pnpm/action-setup@v6
+ uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Set up Node
- uses: actions/setup-node@v6
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 38aa8ebb4..fa1392d93 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -6,16 +6,19 @@ on:
push:
branches: [main]
+permissions:
+ contents: read
+
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- - uses: pnpm/action-setup@v6
+ - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml
index 1a9f99783..60e05283b 100644
--- a/.github/workflows/knip.yml
+++ b/.github/workflows/knip.yml
@@ -16,11 +16,11 @@ jobs:
timeout-minutes: 10
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- - uses: pnpm/action-setup@v6
+ - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
diff --git a/.github/workflows/no-todo-markers.yml b/.github/workflows/no-todo-markers.yml
index f72470531..01d01815d 100644
--- a/.github/workflows/no-todo-markers.yml
+++ b/.github/workflows/no-todo-markers.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
run: |
diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml
index 30ccee8c1..68e067ee6 100644
--- a/.github/workflows/post-publish-verify.yml
+++ b/.github/workflows/post-publish-verify.yml
@@ -70,7 +70,7 @@ jobs:
fi
- name: Log in to GHCR
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index eb08886d1..ebdea3088 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- - uses: pnpm/action-setup@v6
+ - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
@@ -28,12 +28,10 @@ jobs:
- run: pnpm db:generate
+ # Blocking since v1.15.20 — the settings-split refactor that kept
+ # this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
- # Non-blocking on the existing settings page monolith — its set-state-
- # in-effect violations need the 1.4.0 settings-split refactor to fix
- # cleanly. Will flip to blocking once settings/page.tsx is broken up.
- continue-on-error: true
- name: TypeScript strict check
run: pnpm typecheck
@@ -62,32 +60,37 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- - uses: pnpm/action-setup@v6
+ - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
+ # Blocking since v1.15.20 — a HIGH/CRITICAL advisory against a
+ # production dependency fails the build. If an advisory has no
+ # upstream fix yet, pin an `auditConfig.ignoreCves` entry in
+ # package.json (pnpm's documented suppression mechanism) with a
+ # comment referencing the tracking issue, rather than flipping
+ # this step back to warn-only.
- name: Audit for known vulnerabilities
run: pnpm audit --prod --audit-level=high
- continue-on-error: true
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Gitleaks secret scan
- uses: gitleaks/gitleaks-action@v3
+ uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -97,7 +100,7 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Build Docker image
run: docker build -t healthlog:scan .
@@ -109,6 +112,12 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install -y trivy
+ # Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built
+ # image fails the job. For a finding without an upstream fix
+ # (e.g. a base-image CVE Alpine has not patched yet), add the CVE
+ # id to a `.trivyignore` file at the repository root — Trivy reads
+ # it automatically — one CVE per line, each with a comment naming
+ # the tracking issue and the re-check condition. Never suppress a
+ # finding that has a fixed version available; bump instead.
- name: Trivy container scan
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan
- continue-on-error: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5ca58944..defa78106 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,36 @@
## [Unreleased]
+## [1.16.0] — 2026-06-10 — a coach that knows you, invites, and a faster, calmer app
+
+### Added
+
+- **Tell the assistant about yourself.** A new encrypted self-context in Settings → AI — free text plus structured fields for conditions, allergies and what the coach should watch — feeds the coach and the daily briefing as clearly fenced context. After saving, the coach may ask up to three targeted follow-up questions, surfaced as tappable chips above the chat composer.
+- **Proactive coach nudges.** A nightly pass watches four signals — low 7-day adherence, blood pressure above its target band, a falling recovery trend, and a stale self-context — and sends at most one gentle nudge per week through your configured notification channels, with its own opt-out. Off whenever the coach is disabled.
+- **Invite links with QR codes.** Operators on closed registrations can mint expiring invite links from a new admin section: status, uses, expiry and a redemption history of who joined when. Revoking keeps the history; the link and QR are shown exactly once at creation.
+- **Attribute an off-schedule dose to its slot.** History rows that landed off schedule now show when the dose had been due and offer to attribute the intake to that slot — visibly badged, always counted as taken late, reversible at any time.
+- **A time-format preference.** Automatic (follows the language), 24-hour, or 12-hour — set once in the profile, applied to every clock the app renders.
+- **Insight pills are manageable in one place.** The settings list that orders the navigation pills now also hides and shows them, with the same eye toggle the overview tiles use; the page-side manager folded into a link.
+
+### Changed
+
+- **The medication detail page calms down.** Six tabs instead of seven (reminders live with the schedule they gate), every section on the card surface, an overview tab that actually summarises — next dose, reminder state, supply runway — and a history where settled rows carry one quiet menu instead of a button wall. Charts load on demand.
+- **The dashboard loads as one.** Structured skeletons replace bare tiles, and charts reveal together once their data is in instead of popping in one after another.
+- **The medication list stops hammering the server.** Compliance for the cards is computed in one calendar pass per request, cached for fifteen minutes and invalidated on every write; the dashboard drops two redundant queries; the BMI chart reuses the weight series; expensive analytics endpoints gain a shared per-user rate limit.
+- **Nightly assistant runs respect every gate.** Status generation skips users who disabled the coach and honours the operator kill switch, deleted measurements never feed a prompt, the mood card gets the nightly run the other cards always had, non-German locales consistently fall back to English, and worker failures finally reach the error tracker with real retries behind them.
+- **The README says what matters in one screen**, and the repository description and topics match it.
+
+### Fixed
+
+- The wellness ring's glow is no longer clipped to the circle.
+- An overflowing insight-pill row is reachable by mouse — chevrons and wheel scrolling joined touch.
+- Safari favourites no longer render the icon on a white plate.
+- A rate-limited briefing regenerate stops claiming success, and a missing AI provider shows as exactly that instead of an eternal "preparing".
+- Editing schedule times migrates the open dose rows of the old anchors and reconciles a stale intake window instead of stranding both.
+- Auto-missed verdicts respect each schedule's real catch-up window, clear when the dose is taken after all, and skip deleted rows.
+- The intake repair script runs inside the production container.
+- Every workflow action is pinned, dependabot merges only patch and minor updates behind required checks, vulnerability gates block instead of warn, and the published image carries its build SHA so a deployment can prove what is running.
+
## [1.15.19] — 2026-06-10 — honest dose states, bounded bodies, new licence
### Changed
@@ -1021,14 +1051,14 @@ v1.5.5 gave every retired medication feature a home on the detail page, but the
- 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green.
-## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening
+## [1.5.5] — 2026-05-29 — Medication detail page, iOS coordination, outbound-fetch hardening
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
### Added
- **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced.
-- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
+- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. The maintainer's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
- **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218.
- **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217.
- **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load.
@@ -1050,7 +1080,7 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu
### Fixed
-- **Trends-row text overlapping the charts** — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
+- **Trends-row text overlapping the charts** — a specific Insights complaint from a user report. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
- **Grace-row save dead on arrival** — the detail page Settings section PUTs `reminderGraceMinutes` at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation.
- **Purge route did not invalidate server caches** — Tier-3a `Verlauf löschen` dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls `invalidateUserMedications(user.id)` alongside the rollup delete.
- **Bi-weekly worker still emitted every Wednesday** — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy `daysOfWeek` column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows.
@@ -1067,11 +1097,11 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu
- `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch.
- Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs.
- `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`.
-- Pre-tag senior-dev + security architect audit produced two docs at `.planning/medication-detail-page-2026-05-28/F-{1,2}-*.md`. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
+- Pre-tag senior-dev + security architect audit produced two docs (internal working notes). The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
### iOS coord
-- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at `.planning/ios-coord/v155-wire-six-deferred-identifiers.md` + `.planning/ios-coord/v155-step-length-speed-followup.md`.
+- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements live in the internal iOS coordination notes.
- The iOS team flips `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` from pre-multiplied to raw in their next release; until then the server fails-closed (`skipped:"value_out_of_range"`) on > 100% values, so no DB pollution.
### Notes
@@ -1101,7 +1131,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
### Fixed
- **Multi-schedule data-loss risk** — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule `id` round-trips so the PUT preserves identity.
-- **The edit form being wider than its container** — Marc's specific complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
+- **The edit form being wider than its container** — a specific user complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
### Tests
@@ -1111,7 +1141,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
### Notes
-- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the Marc-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
+- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the maintainer-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
- **Cadence-engine read-flip is still v1.5.x deferred** — the today-projector, the cadence chart, and the medication card continue to read the legacy `daysOfWeek` / `intervalWeeks` columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly.
- **Test totals.** 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, `openapi:check`, i18n call-site coverage, locale-integrity all green at HEAD.
@@ -1256,7 +1286,7 @@ Before this change, the iOS HealthKit observer would POST today's running step t
## [1.4.50] — 2026-05-24 — MoodLog reverse-sync (HealthLog → MoodLog push)
-Marc reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap.
+A user reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap.
### Added
@@ -1278,7 +1308,7 @@ The matching MoodLog v0.2.0 release adds the `POST /api/integrations/health-log/
## [1.4.49.4] — 2026-05-24 — BD-tile number truncation + medication-compliance test tz drift
-Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself).
+A user reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself).
### Fixed
@@ -1288,12 +1318,12 @@ Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systo
## [1.4.49.3] — 2026-05-23 — Full i18n call-site audit + 28 stale missing keys filled
-Marc reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle.
+A user reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle.
### Fixed
- Filled 28 missing translation keys across all 6 locales (de / en / es / fr / it / pl = 168 entries), grouped by feature surface that introduced them and never wired the strings:
- - **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on Marc's account rendered the bare keys instead of the localised name + description.
+ - **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on an affected account rendered the bare keys instead of the localised name + description.
- **Welcome carousel** (13 keys — added with the 3-slide intro in v1.4.45): `onboarding.welcome.{title,carouselLabel,slideOf,prevSlide,nextSlide,gotoSlide,cta}` for the chrome plus `slide{1,2,3}.title/body` for the content. The carousel mounted with raw keys for every screen-reader label, dot-pager aria-label, and slide body.
- **Measurement list error state** (1 key — added with v1.4.44): `measurements.loadError`. The error branch in `measurement-list.tsx` rendered the raw key when a fetch failed.
@@ -1307,7 +1337,7 @@ The guard above did not exist before. v1.4.27 B6 introduced `i18n-drift-guard.te
## [1.4.49.2] — 2026-05-23 — Raw i18n key leak on relative-time helper
-Marc reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship.
+A user reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship.
### Fixed
@@ -1327,7 +1357,7 @@ v1.4.49 shipped the slim-slice (`?slice=summaries`) cold-fallback fix, but the p
### Fixed
-- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On Marc's production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged.
+- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On a large production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged.
- Pool starvation that ALSO affected the supposedly-fast `?slice=summaries` request when fired concurrently — the slim slice now resolves in its own SQL budget regardless of whether the default-slice request is also in flight.
### Changed
@@ -1419,7 +1449,7 @@ v1.4.47 closed the high-priority punch list. v1.4.49 bundles the remaining v1.4.
### Deferred
-- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on Marc's Xcode build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`.
+- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on the iOS client's build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`.
- Reactive `prefersReducedMotion()` hook — current helper at `src/lib/charts/reduced-motion.ts` is read-once at render; doesn't react to mid-session OS toggle. Twenty-one call sites would benefit from a `useSyncExternalStore`-backed hook. Scoped as a v1.4.50 single-purpose refactor.
- `handleReminderCheck` extraction — the cron handler in `src/lib/jobs/reminder-worker.ts` is a 260-line non-exported blob. The v1.4.49 suppression skip-path is pinned via the helper `isMedicationReminderClientManaged` rather than a direct integration test of the handler. Scoped as v1.4.50 hygiene.
@@ -1756,7 +1786,7 @@ v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader-
- **`offhost-backup.ts:219` DR-intent comment** — the nightly disaster-recovery S3 snapshot deliberately includes soft-deleted rows so a future "restore from yesterday" brings back a row the user undeleted on the source side. The inline comment now documents the asymmetry vs the user-facing `/api/export/full-backup` which correctly excludes them.
- **`src/lib/doctor-report-data.ts` byte cleanliness** — the file was checked in as `Binary files differ` because the sanitiser regex held literal control bytes (NUL + US + DEL) in its character class. The regex now uses `[\x00-\x1F\x7F]` escape-sequence form so the file becomes UTF-8 clean and future code reviews can read the diffs. Same runtime behaviour; the existing 18-case test suite still passes.
- **Two dead re-exports dropped**: `listSupportedTimezones` re-export in `src/lib/tz/resolver.ts` (callers import from `@/lib/tz/format` directly) and the `describeInjectionSite` re-export in `src/components/medications/glp1-medication-card.tsx` ("Re-export so the parent doesn't need to import it" — no caller ever did).
-- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the wave was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly.
+- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the gate was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly.
### Operator notes
@@ -1823,11 +1853,11 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul
### Changed
- **Per-tile Suspense boundaries on the dashboard.** Each chart tile now mounts inside its own `` so a slow tile no longer blocks the others on first paint. The parent gate is the slim / thick analytics merge (already split per v1.4.39.2); the per-tile Suspense layer is the structural foundation the v1.4.41 React Server Components migration will graft onto. `mood-chart` queryKey dedup eliminates one round-trip on cold mount.
-- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future waves migrate the remaining sites away from bare literals.
+- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future releases migrate the remaining sites away from bare literals.
### Fixed
-- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every Wave-B and Wave-C chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries.
+- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every other dashboard chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries.
- **Soft-delete invisibility full-wire.** Eleven reader-tier helpers across `src/lib/measurements/rollups.ts`, `src/lib/measurements/rollup-coverage.ts`, `src/lib/analytics/{summaries-slice,correlations-fast-path,bp-in-target-fast-path,health-score-fast-path}.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/app/api/dashboard/summary/route.ts`, `src/app/api/measurements/route.ts`, `src/app/api/measurements/series/route.ts`, and `src/lib/ai/coach/snapshot.ts` now filter `deletedAt: null` (or the SQL equivalent `m."deleted_at" IS NULL`) at every aggregate, every cursor walk, every `DISTINCT ON` latest probe, and every rollup-rebuild SQL. Three integration-test contracts in `tests/integration/measurement-soft-delete.test.ts` pin the invariant against a Postgres testcontainer.
- **Six remaining insights `measurement.findMany` sites** that the earlier mood-rollup swap left unfiltered (`/api/insights/{targets,cards,generate}` plus `src/lib/insights/{features,glp1-plateau,pulse-status}.ts`). All six now filter `deletedAt: null` so the iOS-adapter card stream, the AI prompt feature aggregator, the GLP-1 plateau detector window, and the per-type tile-strip averages stop counting tombstoned readings once iOS sync starts emitting deletions.
- **Compliance-rollup hook gap on bulk-projection paths.** Both `/api/medications/intake?scope=today` and `/api/dashboard/summary` mint fresh `(medicationId, scheduledFor)` rows in PENDING state when a daily schedule is projected on first read. Without a recompute hook the rollup for the affected `(user, medication, day)` tuples stayed at its previous (pre-projection) `scheduled` count, which inflated the apparent compliance % until the user logged against the new row. Both call sites now fire one recompute per distinct `(medicationId, dayKey)` tuple, deduplicated through a `Set` so the cost stays bounded. The recompute call is wrapped in `Promise.allSettled` so any future change that lets the helper throw still leaves the user request 200-OK.
@@ -1838,9 +1868,9 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul
### Performance
-Expected on Marc-sized accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window.
+Expected on large accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window.
-- **Wave-C chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` Wave-C burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain.
+- **Late-mounting chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` tile burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain.
- **6 insights routes cold-mount.** The mood-rollup swap on `/api/insights/{features,targets,cards}` moves mood aggregation off the live `MoodEntry.findMany` walk onto the v1.4.39 mood-rollup tier; the cold-mount budget for the affected routes drops onto the same flat-200 ms band the v1.4.39 `/api/mood/analytics` numbers land in.
- **`avg30LastYear` now populated.** The 425-day `since` cap on the `/api/analytics` live-fallback per-type loop (v1.4.39) lets the year-ago baseline tile resolve from raw data without forcing a 347 k-row scan; the v1.4.40 pool cap keeps that fallback off the hot path.
- **`slope90` via MONTH-tier reader.** `readBestGranularityRollups` auto-routes the 90-day slope window onto the MONTH bucket where coverage allows, eliminating a per-cold-mount aggregate on multi-year tenants.
@@ -1942,7 +1972,7 @@ Withings sync, `/api/import`, and admin-restore paths. New writes from
those surfaces now fold the persistent rollup tier on touch, and the
boot-time backfill discovery surfaces every per-(user, type, day) gap
so a worker restart converges any stranded accounts. v1.4.39.2 closes
-two follow-ups Marc surfaced from the post-deploy trace: the dashboard
+two follow-ups the maintainer surfaced from the post-deploy trace: the dashboard
chart still painted its `< 3 daily points` empty-state on the 30-point
range for accounts whose historic rollup partition had not yet been
folded, and the cold-mount UX waterfall left every per-type tile
@@ -2086,7 +2116,7 @@ self-heal via boot-time backfill on first reach.
### Performance
-Expected on Marc-sized accounts; numbers anchored on the v1.4.38 perf
+Expected on large accounts; numbers anchored on the v1.4.38 perf
analysis and confirmed by the unit-test fixture suite. Live perf-verify
rides the post-deploy window.
@@ -2179,7 +2209,7 @@ longer poisons unrelated cards.
### Operator notes
- No new migration. No env-var change.
-- Expected: cold-mount `/api/analytics` on Marc-sized accounts
+- Expected: cold-mount `/api/analytics` on large accounts
drops from 30-75 s to ~3-5 s.
## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger
@@ -2608,7 +2638,7 @@ dashboard SQL surfaced.
load-shedding lands — no semantic change, just a hand-off note.
- The private `dayKey` helper in `bp-in-target-fast-path` renames to
`bucketDayKey` so the cross-file term lines up with the cross-tz
- guard wave naming.
+ guard naming.
- Health-score `bpInTargetPct` reuses the same prior-week query
across windows instead of issuing one read per window.
@@ -2664,7 +2694,7 @@ dashboard SQL surfaced.
`{day}` so a11y labels announce per-weekday).
- `admin`, `settings`, and `achievements` namespaces remain
intentional T3 — operator-facing surface that will land in a
- follow-up wave alongside the iOS launch.
+ follow-up release alongside the iOS launch.
### Security
@@ -2733,7 +2763,7 @@ returns in a single round-trip.
`count / min / max / mean` instead of ~306k bucket rows. The
downstream JS aggregation is bypassed because the per-type
aggregate is already shaped server-side. Cache-miss budget on
- Marc-sized accounts: ~3.1 s → < 100 ms expected.
+ large accounts: ~3.1 s → < 100 ms expected.
### Operator notes
@@ -2983,7 +3013,7 @@ double-digit MB for power users. The Insights page itself stops
blocking the entire shell on `/api/insights/comprehensive`, so each
section renders its own skeleton and fills in independently.
-On top of the perf wave: chart slot heights pinned to kill Recharts'
+On top of the performance work: chart slot heights pinned to kill Recharts'
`width=-1 height=-1` warning and the matching CLS, a tri-state trend
annotation contract that ends the cold-mount "more data needed" flash,
medication intake history restored on the detail page, About card
@@ -3726,8 +3756,7 @@ the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
-schema head comment and `.planning/v15-strategic-plan.md` §5
-decision-log row pin the freeze trigger in-tree.
+schema head comment pins the freeze trigger in-tree.
## [1.4.33] — 2026-05-17 — Polish and reliability
@@ -3922,9 +3951,9 @@ the affected surfaces.
the Insights mother page now accepts both eager and
`next/dynamic` spellings.
-## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first wave
+## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first surface
-First public surface wave for the HealthKit Tier 1 metrics that
+First public surface for the HealthKit Tier 1 metrics that
the iOS contributor brief locked in for v1.5. The headline item
is a workouts flow that lands end-to-end on the web: a list page
at `/insights/workouts`, a detail page with route preview and
@@ -4046,8 +4075,7 @@ sleep before the trigger step lands inside the existing
### Fixed
- **Insights tab-strip blocking on mobile.** Three orthogonal
- client-side fixes per
- `.planning/research/v15-insights-blocking-bug.md`:
+ client-side fixes:
- `fetchAdvisor` gets an 8 s `AbortController`; `AbortError`
falls through to the existing graceful-null return path so the
UI surfaces the regen CTA instead of pinning the query in
@@ -4111,9 +4139,8 @@ Every change is additive on the wire.
error and degrades gracefully through its existing 403
handler. v1.4.31+ iOS reads the `errorCode` to render the
surface-specific empty state.
-- The locked-contract entry at
- `.planning/v15-ios-handoff/08-locked-contracts.md` §14
- documents the rule that the flag matrix gates BOTH
+- The locked-contract coordination notes (§14)
+ document the rule that the flag matrix gates BOTH
server-routed AND on-device assistant surfaces. Apple
Foundation Models on-device Coach + Briefing flows in the
v0.6.0 iOS path honour the same flag matrix as the
@@ -4139,12 +4166,12 @@ standalone-first track depends on lands as §13 of
`MeasurementType`. `requireAuth()` gates the route — any logged-in
user passes — and `Cache-Control: public, max-age=600` enables a
10-minute edge cache. Locked per
- `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
+ the iOS-team coordination response, §3 R1.
### Documented
- **SyncMode conflict-resolution policy.**
- `.planning/v15-ios-handoff/08-locked-contracts.md` gains §13 per
+ The locked-contract coordination notes gain §13 per
R9 of the iOS-team response doc. Hard-spec covers bulk-backfill,
steady-state bidirectional sync via `(updatedAt, syncVersion)`
optimistic concurrency, the 409 write-conflict + 410 Gone
@@ -4188,8 +4215,7 @@ permission picker and the future Insights nav.
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
date)` mints `"stats::"`
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
- locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12
- and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS
+ locked in the iOS coordination notes (§12). iOS
emits one row per day per cumulative type via
`HKStatisticsCollectionQuery`; the unique index collapses
re-syncs idempotently, and a future PATCH-on-divergence call
@@ -4898,8 +4924,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
full filtered set, dedupes once, then slices, so `meta.total`
reports the deduped count rather than the per-window
`canonical.length`.
-- **iOS handoff addendum.** New
- `.planning/v15-ios-handoff/22-standalone-and-server-pairing.md`
+- **iOS handoff addendum.** A new coordination note
documents the standalone-then-pair pattern for the iOS native
client. Sibling of the existing `22-offline-first-architecture.md`;
same research input, neutral framing per the v1.4.27 convention
@@ -5356,7 +5381,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
per-locale bodies (no `REPLY LANGUAGE` footer indirection) with
the full safety-contract matrix in YAML per locale + structural
refusal-probe coverage in CI (see Tests). EN + DE stay
- Marc-maintained. Locale picker covers all six; signup
+ maintainer-curated. Locale picker covers all six; signup
browser-detect maps `fr|es|it|pl` to the corresponding bundle.
- **Coach — native first-party system prompts across six locales.**
Coach + insights system prompts move from EN body + `REPLY
@@ -5950,8 +5975,8 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
### Deferred to v1.4.26
-See `.planning/v1426-backlog.md` for the full backlog with effort
-estimates and source citations. Headline items:
+The full backlog, with effort
+estimates and source citations, is tracked internally. Headline items:
- `User.onboardingGoals` column + server-side persistence for the
goals chip-picker selection (today the picker holds the choice
@@ -6510,7 +6535,7 @@ NOT NULL`) is the defence-in-depth backstop.
- **Coolify image-digest auto-deploy.** Instructions for the
one-time UI-toggle ("Watch image registry for new digests")
- live at `.planning/coolify-auto-deploy-howto.md`. Future
+ live in the internal ops notes. Future
releases should drop the host-side retag fallback the moment
the toggle is on.
- **191 maintainer-name references in `src/` source comments
@@ -6526,7 +6551,7 @@ NOT NULL`) is the defence-in-depth backstop.
### Deferred to v1.4.23
-See `.planning/v1422-backlog.md` for the full carry-over list.
+The full carry-over list is tracked internally.
Highlights: sentinel parser malformed-enum hardening (Sr-M5);
analytics-route unbounded `findMany` paging; targets-route
7-pass sparkline coalesce; `CoachDrawer key={prefill}`
@@ -6828,8 +6853,7 @@ Coach extension for the new measurement types
- 3 HIGH from QA — `/insights` `data?.` narrowing refactor,
`/admin/api-tokens` touch-tooltip (needs Popover swap), and
`/insights` hero density (folded into the v1.4.20 redesign).
-- 31 MED + 16 LOW from the quality-of-life audit. Short-list at
- `.planning/v1420-backlog.md`.
+- 31 MED + 16 LOW from the quality-of-life audit. Short-list tracked internally.
- `/insights` redesign with AI Coach — separate roadmap, design
handoff at `~/Downloads/design_handoff_insights_redesign`.
@@ -6901,9 +6925,9 @@ Coach extension for the new measurement types
`messages/de.json` are still statically imported into the client
bundle, so a determined user can `Cmd-F` for the hidden strings in
`_next/static/chunks/*.js`. Needs a build-time strip or reversible
- obfuscation. Tracked in `.planning/v1419-backlog.md`.
-- See `.planning/v1419-backlog.md` for the short-list and
- `.planning/v15-backlog.md` for the strategic v1.5 items.
+ obfuscation. Tracked internally.
+- The short-list and the strategic v1.5 items are tracked
+ internally.
## [1.4.17] — 2026-05-10
diff --git a/Dockerfile b/Dockerfile
index 3dc224e2d..a883e6c82 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,6 +22,18 @@ WORKDIR /app
ARG NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
+# Short Git SHA of the release commit, same workflow source as the
+# version arg above. Changes exactly when the source changes, so it
+# adds no cache churn beyond what the COPY below already causes.
+# /api/version surfaces it as `buildSha` for deploy verification
+# (docs/ops/deploy.md). The built-at timestamp is intentionally NOT
+# set in this stage: it differs on every run and would bust the
+# `pnpm build` layer cache even for content-identical rebuilds — the
+# runner stage below carries it instead (the route reads process.env
+# at request time, not at bundle time).
+ARG NEXT_PUBLIC_APP_BUILD_SHA
+ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
+
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -54,6 +66,13 @@ ENV TZ=Europe/Berlin
ARG NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
+# Short Git SHA the image was built from — /api/version returns it as
+# `buildSha` so an operator can verify which commit a running `:latest`
+# container actually carries (the deploy runbook checks it after every
+# deploy). Provided by docker-publish.yml alongside the version arg.
+ARG NEXT_PUBLIC_APP_BUILD_SHA
+ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
+
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
@@ -97,6 +116,15 @@ RUN mkdir -p /opt/prisma-cli && \
RUN mkdir -p /opt/geolite2
COPY assets/geolite2/ /opt/geolite2/
+# ISO-8601 build timestamp — /api/version returns it as `builtAt`.
+# Declared this late in the stage on purpose: the value differs on
+# every CI run, and every instruction after an ARG shares its cache
+# fate. Down here the only layers it invalidates are the cheap
+# entrypoint COPY/chmod below; the npm-install layers above stay
+# cache-warm across rebuilds of the same release.
+ARG NEXT_PUBLIC_APP_BUILT_AT
+ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT
+
# Entrypoint script (runs migrations, then starts app)
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
diff --git a/README.md b/README.md
index 5169a335b..67ded7605 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,18 @@
-
+
HealthLog
- Your health data belongs to you. Track it on your own terms.
+ Self-hosted, privacy-first health tracking. Your vitals, your devices, your server.
-
+
-
-
-
-
-
-
-
-
- Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.
- Withings, WHOOP, Google Health/Fitbit, and Apple Health sync, multi-provider AI insights you own, doctor-report PDF.
@@ -31,654 +21,71 @@
Documentation ·
iOS TestFlight
-
----
+
-## Contents
+## Why HealthLog
-- [What it is](#what-it-is)
-- [Why HealthLog?](#why-healthlog)
-- [How it compares](#how-it-compares)
-- [Key Features](#key-features)
-- [Quick Start](#quick-start)
-- [Tech Stack](#tech-stack)
-- [Security and Privacy](#security-and-privacy)
-- [Environment Variables](#environment-variables)
-- [Architecture](#architecture)
-- [API Reference](#api-reference)
-- [Integrations](#integrations)
-- [Local Development](#local-development)
-- [Deployment](#deployment)
-- [Documentation](#documentation)
-- [Roadmap](#roadmap)
-- [Native iOS client](#native-ios-client)
-- [Contributing](#contributing)
-- [License](#license)
+HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
----
+Try the [live demo](https://demo.healthlog.dev) to see a working install.
-## What it is
+## Highlights
-HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings, WHOOP, and Google Health/Fitbit devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. PolyForm Noncommercial 1.0.0.
+- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
+- **Your devices, one server.** Withings and WHOOP sync over OAuth2, Google Health/Fitbit connects experimentally, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
+- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
+- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with proactive check-ins. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
+- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
+- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
+- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, English and German end to end.
-> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.12 — a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental, see [Integrations](#integrations)) and a rebuilt mood log (five-face capture, a structured tag catalog, and rated daily-life factors), on top of the v1.11 mood-relations and sleep-depth insights, a read-only FHIR R4 API with shareable clinician records, the v1.10 derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), and the v1.7 health-record export (PDF + FHIR R4). The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
+The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
-> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
-
-Built for people who want their health data on their own server -- whether that's a NAS, a homelab, or a small VPS -- and who don't want to hand it to a US cloud to read a 7-day weight trend. **Try the [live demo](https://demo.healthlog.dev)** to see what a working install looks like, or skip to [Quick Start](#quick-start) below.
-
----
-
-## Why HealthLog?
-
-Most health apps lock your data behind proprietary clouds, push subscriptions, and sell your metrics to advertisers. HealthLog takes a different approach: your data stays on your server, encrypted at rest, accessible only to you.
-
----
-
-## How it compares
-
-| | HealthLog | Withings web | Apple Health | Oura web | WHOOP web | Fitbit web | Generic CSV |
-| -------------------------- | -------------------- | --------------- | ------------- | ----------- | ----------- | ------------ | ----------- |
-| Self-hosted | Yes | No | No | No | No | No | Yes |
-| Source-available | PolyForm-NC-1.0.0 | No | No | No | No | No | n/a |
-| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No | No | No |
-| WHOOP device sync | Yes (OAuth2, BYO-keys)| No | No | No | Native | No | No |
-| Google Health / Fitbit sync| Yes (experimental) | No | No | No | No | Native | No |
-| Apple Health import | Yes (`export.zip`) | No | Native | No | No | No | Manual |
-| Aggregates many providers | Yes | No | Partial | No | No | No | Manual |
-| Multi-source dedup + priority| Yes (per-metric) | n/a | Limited | n/a | n/a | n/a | No |
-| Blood pressure + glucose | First-class | BP only | Manual | No | No | Limited | Manual |
-| Medication compliance | Cadence-aware | No | No | No | No | No | Manual |
-| Derived wellness metrics | Yes (transparent) | Limited | Limited | Yes (closed)| Yes (closed)| Limited | No |
-| Custom clinician targets | Yes (audit-logged) | Limited | No | No | No | No | n/a |
-| Doctor-report PDF + FHIR | Yes (client-side) | No | No | No | No | No | n/a |
-| AI Insights | Multi-provider BYOK | No | Limited | Subscription| Subscription| Limited | n/a |
-| Encrypted at rest (AES-256-GCM)| Yes (your host) | Vendor cloud | Vendor cloud | Vendor cloud| Vendor cloud| Vendor cloud | Depends |
-| Subscription required | No | For some metrics| No | Yes | Yes | For some metrics| No |
-| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | WHOOP cloud | Fitbit cloud | Depends |
-
-### Versus WHOOP / Oura / Fitbit ecosystems
-
-The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
-
-- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. PolyForm Noncommercial 1.0.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
-- **It aggregates across providers instead of locking you to one.** Withings and WHOOP devices sync over OAuth2, a Google Health/Fitbit connection pulls Fitbit and Pixel data (experimental), an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, a WHOOP strap, an Apple Watch, and a glucometer land on one timeline instead of four apps. When two providers report the same metric, a per-metric source priority decides which one is canonical, so a dedicated scale outranks a wrist estimate and cumulative metrics like steps are never double-counted (see [Source priority](#source-priority)).
-- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
-- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
-
-HealthLog is the right fit when you want your wearable and clinical numbers in one place that you control, with explainable analytics on top — not when you want a closed device that scores you and keeps the data.
-
----
-
-## Key Features
-
-**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESH 2023, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices. Every stored HealthKit metric now has a chart surface — flights climbed, audio exposure, walking speed/step-length/asymmetry/double-support, respiratory rate, the body-composition family, mobility, daylight, and more — and a metric/imperial display toggle renders walking speed in km/h while storage and exports stay canonical SI.
-
-**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
-
-**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
-
-**Mood Logging** -- A five-face mood capture (a 1–5 scale) backed by a structured tag catalog and rated daily-life factors. Pick binary tags from categories covering feelings, sleep, health (including nutrition tags like ate-well, fast-food, no-sweets, big-meal), social, work, and hobbies; score rated factors — work, social, sleep quality, stress, and conflict — on their own scale (1–5, or yes/no for conflict), with stress and conflict flagged as inverse so a higher score reads as a worse day. Add a free-text note, then read it back through trend analytics and a mood-relations surface that associates your tags and factors with your other metrics. The standalone moodLog.app webhook is deprecated now that mood is tracked fully inside HealthLog; it stays functional for existing integrations.
-
-**Medication Compliance** -- Flexible scheduling driven by a single recurrence engine: time windows, day-of-week recurrence, `intervalWeeks`, rolling intervals, RFC-5545 RRULE, one-time doses, as-needed (PRN), and cyclic on/off-week plans — so once-weekly GLP-1 injections, weekday-only doses, and non-daily plans score honestly against their real cadence rather than a daily denominator, everywhere from the dashboard tile to the detail page. Route of administration (oral / injection / other) carries the injection-site picker for any injection. Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
-
-**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
-
-**WHOOP Integration** -- Connect a WHOOP account with your own WHOOP developer OAuth client (client id/secret pasted into Settings, stored encrypted). Recovery, strain, sleep, body, and workout data sync server-side over OAuth2, with a connection test, manual sync (incremental or full history), and a resume control to recover from a revoked grant.
-
-**Google Health / Fitbit Integration** _(experimental)_ -- Connect Fitbit and Pixel data through Google's Health API on your own Google Cloud OAuth client (client id/secret in Settings, encrypted at rest). Activity (steps, distance, active energy, floors, VO₂ max), health metrics (weight, body fat, SpO₂, resting heart rate, HRV, respiratory rate, wrist temperature, spot heart rate), per-stage sleep, and exercise workouts import server-side with the same connection-test / sync / resume controls as WHOOP. Because the Google Health API is young and several per-type value fields are not yet fully published, this connection is **experimental** — some data types may import nothing until the wire mapping is verified against a live account, and each operator's OAuth client needs Google brand verification plus an annual CASA assessment before it can leave Testing mode.
-
-**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every ``, ``, ``, and `` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
-
-**AI Coach + Insights** -- Multi-provider, evidence-grounded health insights: per-metric assessments that read each vital against its reference range, correlations across your metrics, a daily briefing, a weekly report, a Health Score tile, and a conversational Coach grounded in your own data. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
-
-
-
-
-
-**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
-
-**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
-
-**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
-
-**Clinician-grade FHIR API + shareable record** -- A read-only HL7 FHIR R4 REST API (`GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,MedicationAdministration,$everything}`) lets a clinician system pull your record in a standard shape, gated behind a dedicated `fhir:read` token scope. Alongside it, a shareable clinician record: create a scoped, time-limited share link, hand a clinician the read-only `/c/` view, and revoke it when the visit is over. The two are not yet wired together — a share link does not expose the FHIR API (`capabilities.share.fhirApi=false`) — but each stands on its own.
-
-**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
-
-**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access. A paginated, opaque-cursor sync delta feed (`GET /api/sync/changes`) with measurement tombstones lets native and offline clients reconcile against the server incrementally.
-
-**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), Web Push (VAPID), and Apple Push (APNs) for the native iOS client. One dispatcher fans medication reminders out to whichever channels the user has enabled, with late/missed escalation.
-
-**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported. The above-the-fold tiles arrive together in a single first-paint snapshot, and a nightly job pre-generates the daily briefing so `/insights` is a cache read rather than a synchronous model call.
-
-**Gamification** -- 59 persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
-
-**Internationalization** -- English (default) and German UI with 2500+ translation keys each, guarded by a CI integrity test that fails the build on duplicate keys, drift between locales, or call-sites referencing keys that no locale ships. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
-
-**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted daily backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI (`scripts/rotate-encryption-key.ts`), optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
-
-**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
-
----
-
-## Quick Start
-
-**3 minutes from `git clone` to a working install.** The bundled `docker-compose.yml` pulls a pre-built multi-arch image (`linux/amd64` + `linux/arm64`) from [GitHub Container Registry](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required for self-hosters. Contributors who want to test local changes can `docker compose up --build`.
+## Quick start
```bash
-git clone https://github.com/MBombeck/HealthLog.git
-cd HealthLog
+git clone https://github.com/MBombeck/HealthLog.git && cd HealthLog
cp .env.example .env
-```
-
-Generate the three required secrets and paste them into `.env`:
-
-```bash
echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
-echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
-echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
-```
-
-Then bring the stack up:
-
-```bash
+echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
+echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
docker compose up -d
```
-Open **http://localhost:3000**. The first registered user becomes admin.
+Open **http://localhost:3000** — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required. Behind a reverse proxy, set `NEXT_PUBLIC_APP_URL` and `APP_URL` first — see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/).
-> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
+## Self-hosting
----
+One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted daily backups to any S3-compatible bucket are opt-in via the admin panel, new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
-## Tech Stack
+HealthLog ships releases roughly weekly. Pin a tag, back up before upgrades, and skim the [CHANGELOG](CHANGELOG.md) before pulling `latest`. The operator manual — reverse proxy, migrations, encryption-key rotation, backup and restore — lives at [docs.healthlog.dev](https://docs.healthlog.dev).
-| Layer | Technology |
-| ------------- | ------------------------------------------------- |
-| Framework | Next.js 16 (App Router, React Server Components) |
-| Language | TypeScript (strict mode) |
-| Database | PostgreSQL 16 + Prisma 7 (61 models) |
-| Job Queue | pg-boss 12 (reminders, insights, backups) |
-| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
-| Charts | Recharts 3 |
-| Data Fetching | TanStack Query 5 |
-| Forms | React Hook Form 7 + Zod 4 |
-| Auth | SimpleWebAuthn 13, Argon2id |
-| Notifications | Telegram Bot API, ntfy, Web Push (VAPID), APNs |
-| PDF | jsPDF (client-side generation) |
-| Testing | Vitest 4 |
-| Deployment | Docker (multi-stage Alpine) |
-| Native client | SwiftUI iOS app — public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa) |
+## Native iOS app
----
+A SwiftUI companion in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa), built on the same API as the web client: live HealthKit two-way sync, medication reminders with action buttons that work without opening the app, and an on-device coach on Apple-Intelligence-capable iPhones. Code lives in [MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
-## Security and Privacy
+## Tech stack
-HealthLog is designed for people who take data ownership seriously.
-
-- **Self-hosted** -- Your data never leaves your server. No telemetry, no third-party tracking.
-- **AES-256-GCM encryption** -- All stored secrets (OAuth tokens, API keys, VAPID keys, notification credentials, off-host backup payloads) are encrypted at rest.
-- **Your AI, your keys** -- AI insights run on a provider you choose: bring your own OpenAI / Anthropic key, sign in to ChatGPT via Codex device-OAuth, or point at a local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM) so your health data never leaves your network. No model calls are made without a key you supply.
-- **Key versioning + zero-downtime rotation** -- Multiple encryption keys can coexist (`ENCRYPTION_KEYS` map + `ENCRYPTION_ACTIVE_KEY_ID`) and a CLI (`pnpm dlx tsx scripts/rotate-encryption-key.ts`) re-wraps every encrypted column from the old key to the new one without taking the app offline.
-- **Passkey authentication** -- WebAuthn as primary auth with password fallback (Argon2id + zxcvbn strength validation).
-- **Server-side sessions** -- PostgreSQL-backed with 30-day sliding expiry, HttpOnly/SameSite=Strict cookies.
-- **Security headers** -- CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy, Referrer-Policy.
-- **Rate limiting** -- Sliding window on auth and API endpoints.
-- **HMAC-SHA256 API tokens** -- Bearer tokens are hashed before storage.
-- **Offline IP geolocation** -- Bundled MaxMind GeoLite2 City + ASN databases resolve admin login-overview IPs without round-tripping to a third party. Public `ipwho.is` is only consulted when the local lookup misses.
-- **Wide-event structured logging** -- Every API route emits a single envelope with `action`, latency, request id, sampled payload (with secret redaction), and an optional Loki transport for self-hosted log aggregation.
-- **Audit logging** -- All sensitive operations tracked with IP addresses, dedup-windowed to keep the ledger compact under bursty writes.
-
-
-
-
-
----
-
-## Environment Variables
-
-### Required
-
-| Variable | Description |
-| -------------------- | ------------------------------------------------------------- |
-| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
-| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
-| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
-| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
-
-### Optional
-
-| Variable | Description |
-| ------------------------- | ---------------------------------------------------- |
-| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
-| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
-| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
-| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
-| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
-| `WHOOP_REDIRECT_URI` | WHOOP OAuth callback URL (client id/secret set in Settings) |
-| `WHOOP_WEBHOOK_SECRET` | HMAC secret for WHOOP webhook signature verification |
-| `FITBIT_REDIRECT_URI` | Google Health/Fitbit OAuth callback URL (defaults to `/api/fitbit/callback`; client id/secret set in Settings) |
-| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
-
-Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
-
----
-
-## Architecture
-
-Data flows from every source — Withings, an Apple Health `export.zip`, the iPhone's live HealthKit, manual entry, moodLog — through the ingest endpoints into a single `Measurement` table and its pre-aggregated rollups, then back out to the dashboard, insights, Coach, and the doctor-report PDF.
-
-
-
-
-
-```
-src/
-├── app/ # Next.js App Router pages & API routes
-│ ├── api/ # REST API endpoints (180+ route files)
-│ ├── admin/ # Admin panel
-│ ├── auth/ # Login, register, passkey enrolment
-│ ├── medications/ # Medication management
-│ ├── measurements/ # Health metric entry
-│ ├── mood/ # Mood log
-│ ├── insights/ # AI-powered analytics
-│ ├── charts/ # Long-form charts
-│ ├── achievements/ # Gamification page
-│ ├── targets/ # Custom thresholds dashboard
-│ ├── notifications/ # Notification preferences matrix
-│ ├── bugreport/ # Built-in feedback / bug report
-│ ├── onboarding/ # 4-step guided setup
-│ └── settings/ # User preferences (8 top-level sections)
-├── components/
-│ ├── ui/ # shadcn/ui primitives
-│ ├── layout/ # Shell (sidebar, topbar, bottom nav)
-│ ├── medications/ # Medication cards, forms, timeline
-│ ├── measurements/ # Measurement form, list
-│ ├── mood/ # Mood form, mood list
-│ ├── charts/ # Recharts wrappers
-│ ├── insights/ # AI insight status / advisor cards
-│ ├── gamification/ # Achievement cards, progress
-│ ├── monitoring/ # Umami / GlitchTip bootstrap
-│ └── settings/ # Settings-page section components
-├── lib/
-│ ├── auth/ # Session, audit, passkey logic
-│ ├── notifications/ # Dispatcher + channel senders
-│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
-│ ├── analytics/ # Trend calculations, compliance, correlations
-│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, Codex, local)
-│ ├── insights/ # Insight pipeline + medical prompts
-│ ├── gamification/ # Achievement definitions
-│ ├── feedback/ # Built-in feedback + GitHub escalation
-│ ├── moodlog/ # moodLog.app webhook + sync
-│ ├── monitoring/ # Umami / GlitchTip server-side hooks
-│ ├── withings/ # OAuth client, sync service
-│ ├── logging/ # Wide Events: builder, context, transports
-│ ├── i18n/ # Translations context & config
-│ ├── validations/ # Shared Zod schemas
-│ ├── api-handler.ts # apiHandler wrapper, requireAuth/requireAdmin
-│ ├── api-response.ts # { data, error } envelope helpers
-│ ├── crypto.ts # AES-256-GCM encrypt/decrypt
-│ ├── rate-limit.ts # Sliding-window rate limiter
-│ ├── db.ts # Prisma singleton
-│ └── doctor-report-pdf.ts # Client-side PDF generation
-├── hooks/ # React hooks
-└── generated/prisma/ # Generated Prisma client
-```
-
-### Key Patterns
-
-- **RSC by default** -- `"use client"` only for interactive components
-- **API envelope** -- All responses follow `{ data, error }` shape via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`
-- **apiHandler wrapper** -- Every API route wraps its handler in `apiHandler()` (`src/lib/api-handler.ts`) for consistent error handling, Wide-Event structured logging, and `x-request-id` propagation
-- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials
-- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database
-- **Route protection** -- `proxy.ts` (Next.js 16's renamed middleware) checks session cookie, redirects unauthenticated requests
-- **Client-side PDF** -- Doctor reports generated in browser via jsPDF
-
----
-
-## API Reference
-
-All mutations require authentication via session cookie. External ingest uses Bearer tokens. A machine-readable OpenAPI 3.1 spec for the iOS-locked native subset lives at [`docs/api/openapi.yaml`](docs/api/openapi.yaml) — the source of truth for any client codegen (Swift / Kotlin / OpenAPI Generator).
-
-
-Health Data
-
-| Method | Endpoint | Description |
-| -------- | ----------------------- | ----------------------------------------- |
-| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
-| `POST` | `/api/measurements` | Create measurement |
-| `DELETE` | `/api/measurements/:id` | Delete measurement |
-| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
-| `GET` | `/api/export` | Export as CSV or JSON |
-| `POST` | `/api/import` | Import from JSON |
-| `POST` | `/api/doctor-report` | Aggregated data for PDF |
-
-
-
-
-Mood
-
-| Method | Endpoint | Description |
-| -------- | ----------------------------------- | -------------------- |
-| `GET` | `/api/mood-entries` | List mood entries |
-| `POST` | `/api/mood-entries` | Create mood entry |
-| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
-| `GET` | `/api/mood/analytics` | Mood trend analytics |
-| `GET` | `/api/mood/tags` | Structured mood-tag + rated-factor catalog |
-| `GET` | `/api/mood/insights` | Mood-relations associations |
-| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook (deprecated) |
-
-
-
-
-Medications
-
-| Method | Endpoint | Description |
-| -------- | --------------------------------- | ------------------------ |
-| `GET` | `/api/medications` | List all medications |
-| `POST` | `/api/medications` | Create medication |
-| `PUT` | `/api/medications/:id` | Update medication |
-| `DELETE` | `/api/medications/:id` | Delete medication |
-| `POST` | `/api/medications/:id/intake` | Log intake event |
-| `GET` | `/api/medications/:id/compliance` | Compliance stats |
-| `POST` | `/api/ingest/medication` | External intake (Bearer) |
-
-
-
-
-Auth and Integrations
-
-| Method | Endpoint | Description |
-| ------- | -------------------------------- | ----------------------------------- |
-| `POST` | `/api/auth/register` | Create account |
-| `POST` | `/api/auth/login` | Password login |
-| `POST` | `/api/auth/logout` | Destroy session |
-| `GET` | `/api/auth/me` | Current user profile + avatar URL |
-| `POST` | `/api/auth/password` | Change password |
-| `PATCH` | `/api/auth/profile` | Update profile fields |
-| `POST` | `/api/user/avatar` | Upload a profile photo (self-hosted)|
-| `DELETE`| `/api/user/avatar` | Remove the profile photo |
-| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
-| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
-| `POST` | `/api/auth/codex/device-start` | ChatGPT (Codex) device-OAuth start |
-| `POST` | `/api/auth/codex/device-poll` | Codex device-OAuth poll for token |
-| `POST` | `/api/auth/codex/disconnect` | Revoke the stored Codex session |
-| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
-| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
-| `POST` | `/api/withings/webhook` | Withings notification webhook |
-| `GET` | `/api/whoop/connect` | Initiate WHOOP OAuth |
-| `POST` | `/api/whoop/sync` | Manual WHOOP sync (`{ fullSync }` for full history) |
-| `POST` | `/api/integrations/whoop/test` | Probe a saved WHOOP connection |
-| `POST` | `/api/integrations/whoop/resume` | Resume a WHOOP connection after a revoked grant |
-| `GET` | `/api/fitbit/connect` | Initiate Google Health/Fitbit OAuth |
-| `POST` | `/api/fitbit/sync` | Manual Fitbit sync (`{ fullSync }` for full history) |
-| `POST` | `/api/integrations/fitbit/test` | Probe a saved Google Health/Fitbit connection |
-| `POST` | `/api/integrations/fitbit/resume`| Resume a Fitbit connection after a revoked grant |
-| `POST` | `/api/insights/generate` | Regenerate AI insights |
-| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
-| `GET` | `/api/gamification/achievements` | Achievement progress |
-| `GET` | `/api/health` | Docker health check |
-
-
-
-
-Personalization (Thresholds + Dashboard)
-
-| Method | Endpoint | Description |
-| ------ | -------------------------- | ---------------------------------------------- |
-| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
-| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) |
-| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) |
-| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
-| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
-| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
-
-
-
-
-Feedback + API Tokens
-
-| Method | Endpoint | Description |
-| -------- | ----------------------- | ----------------------------------- |
-| `POST` | `/api/feedback` | Submit in-app feedback |
-| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
-| `GET` | `/api/tokens` | List own API tokens |
-| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
-| `DELETE` | `/api/tokens/:id` | Revoke API token |
-
-
-
-
-Notifications
-
-| Method | Endpoint | Description |
-| ------ | -------------------------------- | ----------------------------------- |
-| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
-| `PUT` | `/api/notifications/preferences` | Update preferences |
-| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
-| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
-| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
-
-
-
-
-Admin (admin role required)
-
-| Method | Endpoint | Description |
-| ------ | ------------------------------------- | ------------------------------------- |
-| `GET` | `/api/admin/status` | System + integration status |
-| `GET` | `/api/admin/users` | List users |
-| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
-| `GET` | `/api/admin/feedback` | All feedback / bug reports |
-| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
-| `GET` | `/api/admin/audit-log` | Audit-log viewer |
-| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
-| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
-| `GET` | `/api/admin/tokens` | All issued API tokens |
-| `POST` | `/api/admin/notifications/test` | Send test notification |
-| `GET` | `/api/admin/data` | Data backups + counts |
-| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
-| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
-
-
-
-
-Public + v1.4 additions
-
-| Method | Endpoint | Description |
-| ------ | ---------------------------------- | ----------------------------------------------- |
-| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
-| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
-| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
-| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
-| `POST` | `/api/admin/monitoring/glitchtip-test` | Trigger a Glitchtip ingest probe (admin) |
-| `POST` | `/api/admin/monitoring/umami-test` | Verify Umami script + website ID resolve (admin) |
-| `POST` | `/api/auth/refresh` | Native client refresh-token rotation (POST body opts into revoke) |
-
-
-
-
-FHIR + clinician sharing (v1.11)
-
-Read-only HL7 FHIR R4 REST API, gated behind the `fhir:read` token scope:
-
-| Method | Endpoint | Description |
-| ------ | -------------------------------------- | ---------------------------------------------------- |
-| `GET` | `/api/fhir/metadata` | FHIR CapabilityStatement |
-| `GET` | `/api/fhir/Patient` | Patient resource for the token's user |
-| `GET` | `/api/fhir/Observation` | Vitals + labs as LOINC-coded observations |
-| `GET` | `/api/fhir/MedicationStatement` | Medication regimen |
-| `GET` | `/api/fhir/MedicationAdministration` | Logged intake events |
-| `GET` | `/api/fhir/$everything` | Bundle of every resource above |
-
-Clinician share-link lifecycle — create a scoped, time-limited link, hand a clinician the read-only `/c/` view, and revoke it after the visit. Share links do not expose the FHIR API (`capabilities.share.fhirApi=false`).
-
-
-
----
-
-## Integrations
-
-| Integration | Setup | Purpose |
-| ------------------------ | ------------- | ---------------------------------------- |
-| **Withings** | Env vars | Auto-sync weight, BP, and activity |
-| **WHOOP** | User Settings | OAuth2 sync of recovery, strain, sleep, body, and workouts (BYO-keys) |
-| **Google Health / Fitbit** | User Settings | OAuth2 sync of activity, health metrics, sleep, and workouts for Fitbit/Pixel (BYO-keys, **experimental**) |
-| **Telegram** | Admin Panel | Medication reminders with inline buttons |
-| **ntfy** | User Settings | Self-hosted push notifications |
-| **Web Push** | Admin Panel | Browser-native VAPID notifications |
-| **OpenAI / Anthropic / local** | User Settings | AI health insights (BYOK or local endpoint) |
-| **moodLog.app** | User Settings | Mood tracking sync (deprecated — mood is now tracked natively) |
-| **Umami** | Admin Panel | Privacy-friendly analytics |
-| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
-
-### Source priority
-
-When two providers report the same metric — a Withings scale and an Apple Watch both logging weight, or HealthKit and Fitbit both reporting steps — HealthLog keeps every row as an audit trail but applies a per-metric source priority to decide which reading is canonical. Cumulative metrics (steps, distance, active energy, floors) pick exactly one source per day so they are never double-counted; point measurements (weight, blood pressure, HRV) pick a display-preferred source. The default ladder reflects measurement reliability for each metric class: a dedicated scale outranks a wrist estimate for weight, a recovery strap leads the sleep and HRV ladders, and a cuff leads blood pressure. The ladder is per-user adjustable, and an optional device-type tie-break decides between two devices on the same source. The underlying idea is [data fusion](https://en.wikipedia.org/wiki/Data_fusion) — combining several imperfect sensors into a single, more reliable estimate rather than averaging them blindly.
-
-
-
-
-
----
-
-## Local Development
-
-```bash
-# Prerequisites: Node.js 22, pnpm, PostgreSQL
-
-cp .env.example .env
-pnpm install
-pnpm db:generate
-pnpm db:migrate
-pnpm dev
-```
-
-### Scripts
-
-```bash
-pnpm dev # Development server
-pnpm build # Production build
-pnpm lint # ESLint
-pnpm typecheck # TypeScript strict check
-pnpm test # Vitest
-pnpm format # Prettier
-
-pnpm db:generate # Generate Prisma client
-pnpm db:migrate # Create & apply migration
-pnpm db:migrate:deploy # Apply migrations (production)
-pnpm db:studio # Prisma Studio GUI
-```
-
----
-
-## Deployment
-
-The included `docker-compose.yml` runs the app and PostgreSQL. The entrypoint automatically waits for the database, runs pending migrations, and starts the server.
-
-The app listens on port **3000**. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with [Coolify](https://coolify.io/).
-
-
-
-
-
-For a single-process default the same container hosts both the web and worker (`HEALTHLOG_PROCESS_TYPE=all`, the default). Split them via `HEALTHLOG_PROCESS_TYPE=web` and `HEALTHLOG_PROCESS_TYPE=worker` for horizontal scale. Off-host AES-GCM daily backups to any S3-compatible bucket (R2, B2, MinIO, AWS) are opt-in via the admin panel. See [`docs/self-hosting/`](docs/self-hosting/) and [`docs/ops/`](docs/ops/) for the full operator manual.
-
----
+Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
## Documentation
-The full documentation set lives under [`docs/`](docs/) (also published at [docs.healthlog.dev](https://docs.healthlog.dev)). A technical visitor browsing the source will also find a navigational `README.md` in each key code directory (`src/`, `src/lib/`, `src/app/`, `src/components/`, `prisma/`, `scripts/`, `e2e/`).
+| | |
+| ---------------------- | ------------------------------------------------ |
+| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
+| Release history | [CHANGELOG.md](CHANGELOG.md) |
+| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
+| Security policy | [SECURITY.md](SECURITY.md) |
+| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
-| Area | Where | What it covers |
-| ---- | ----- | -------------- |
-| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling, [reproductive-health data on your own server](docs/self-hosting/reproductive-health-data.md) |
-| Threat model | [`docs/security/cycle-data-threat-model.md`](docs/security/cycle-data-threat-model.md) | What self-hosting cycle data does and does not protect against |
-| Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning |
-| API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset |
-| Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers |
-| Architecture diagrams | [`docs/diagrams/`](docs/diagrams/) | The five SVG diagrams embedded above |
-| Migration notes | [`docs/migration/`](docs/migration/) | Cross-version upgrade notes |
-| Design records | [`docs/adr/`](docs/adr/), [`docs/ui-guidelines.md`](docs/ui-guidelines.md) | Architecture decision records + UI conventions |
-| Contributor manual | [`CLAUDE.md`](CLAUDE.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) | Conventions, branch/release model, the critical-files map |
+## Status
----
-
-## Roadmap
-
-| Release line | Focus |
-| ------------ | ----- |
-| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
-| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
-| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
-| **v1.8 – v1.10** | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
-| **v1.11 – v1.12** (current) | WHOOP device sync, a read-only HL7 FHIR R4 API with shareable clinician records, mood-relations and sleep-depth (hypnogram) insights (v1.11); then a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental) and a rebuilt mood log — five-face capture, a structured tag catalog, and rated daily-life factors (v1.12). |
-| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
-
-Two known limitations carry forward from v1.11: dedup across two sources of the same standard vital is deferred (both rows persist, source priority decides display), and the coach's durable conversation-summary / remembered-facts layer is deferred — each answer reasons from your data afresh rather than from a running memory.
-
-The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
-
----
-
-## Native iOS client
-
-
- Join the TestFlight public beta →
-
-
-A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client — same server, same data, same source of truth. The phone is a view + a write-ahead log; your server is the database. Code lives in a separate repo: [github.com/MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
-
-### What the phone unlocks
-
-- **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other.
-- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app.
-- **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone.
-- **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line.
-- **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface.
-
-### Built on Stanford Spezi
-
-The iOS client adopts the [Stanford Spezi](https://spezi.stanford.edu) digital-health framework where it earns its keep — battle-tested code maintained by Stanford's Biodesign Digital Health group rather than rolled by hand:
-
-- **SpeziHealthKit** for the HealthKit-read pipeline (live and historical),
-- **SpeziChat** under the on-device AI Coach surface,
-- **SpeziFHIR** + **HealthKitOnFHIR** for the doctor-report export bundle,
-- **SpeziAccessGuard**, **SpeziScheduler**, **SpeziMedication** queued for the next release line.
-
-Spezi exists because medical-device-grade software needs medical-device-grade primitives — the same modules ship in clinical-trial apps at Stanford Medicine. Adopting them gives the iOS client a quality + security floor a solo-maintainer code-base could not otherwise hit.
-
-### Status
-
-`v0.6.1.x` ships almost daily; the [iOS repo CHANGELOG](https://github.com/MBombeck/healthlog-iOS/blob/main/CHANGELOG.md) and tag list track what landed in each build. German-primary UI with English secondary. iOS 18+ minimum (iOS 26+ for the on-device Coach). PolyForm Noncommercial 1.0.0, same as this repo.
-
-
- TestFlight beta ·
- iOS repo ·
- iOS issues
-
-
----
-
-## Contributing
-
-Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
-
-- Code style: `pnpm format && pnpm lint`
-- Type safety: `pnpm typecheck` must pass
-- Tests: `pnpm test`
-- UI language: English by default, German selectable per user. Code, comments, and commits: English.
-
----
+Actively developed — new releases roughly weekly, issue reports and PRs welcome. Behaviour and schema can change between versions; migrations are forward-only.
## License
-HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE). It is free to use, self-host, and modify for noncommercial purposes; commercial use requires a separate agreement with the maintainers.
-
-Releases up to and including v1.15.18 were published under the GNU Affero General Public License v3.0 and remain available under that license.
+HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement — open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license.
---
@@ -687,5 +94,5 @@ Releases up to and including v1.15.18 were published under the GNU Affero Genera
Live Demo ·
Docs ·
iOS TestFlight ·
- Buy Me A Coffee
+ Buy Me a Coffee
diff --git a/docker-compose.yml b/docker-compose.yml
index 1f382c67a..f6c68cbb6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,6 +15,11 @@ services:
image: ghcr.io/mbombeck/healthlog:latest
pull_policy: always
container_name: healthlog-app
+ # The app runs as the unprivileged `nextjs` user and never needs a
+ # setuid/setgid escalation; blocking privilege gain caps what a
+ # compromised process inside the container can do.
+ security_opt:
+ - no-new-privileges:true
ports:
- "3000:3000"
environment:
@@ -125,8 +130,16 @@ services:
# restart: unless-stopped
db:
- image: postgres:16-alpine
+ # Pinned to the concrete minor instead of the floating `16-alpine`
+ # alias so a database update is always a deliberate, reviewable
+ # change (at pin time `16-alpine` resolved to this same digest, so
+ # existing deployments see no image change). Minor releases share
+ # the on-disk format — bumping this pin is safe without a dump /
+ # restore; a major-version bump (17+) is not.
+ image: postgres:16.14-alpine
container_name: healthlog-db
+ security_opt:
+ - no-new-privileges:true
environment:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 85602e835..48e6f8c6e 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.15.19
+ version: 1.16.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -3275,6 +3275,187 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/coach/about-me:
+ get:
+ tags:
+ - Insights
+ summary: Read the caller's self-context
+ description: v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus)
+ the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any
+ pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null
+ (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session.
+ responses:
+ "200":
+ description: The stored fields (null when never written / cleared) plus pending clarifying questions.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetCoachAboutMeResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ put:
+ tags:
+ - Insights
+ summary: Write (or clear) the caller's self-context
+ description: "v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each)
+ encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the
+ stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying
+ questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise)
+ and returns them as `pendingQuestions`. Rate-limited per user."
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ aboutMe:
+ type: string
+ maxLength: 4000
+ conditions:
+ type: string
+ maxLength: 500
+ allergies:
+ type: string
+ maxLength: 500
+ coachFocus:
+ type: string
+ maxLength: 500
+ required:
+ - aboutMe
+ responses:
+ "200":
+ description: The effective (trimmed) state echoed back plus the freshly derived pending questions.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PutCoachAboutMeResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/coach/about-me/questions:
+ get:
+ tags:
+ - Insights
+ summary: Read the pending clarifying questions
+ description: v1.16.0 — the up-to-3 clarifying questions derived after the last self-context save. The Coach composer
+ renders them as tappable suggestion chips. Stored encrypted; an undecryptable payload reads as an empty list
+ (fail closed).
+ responses:
+ "200":
+ description: The pending questions (possibly empty).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetCoachAboutMeQuestionsResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ delete:
+ tags:
+ - Insights
+ summary: Dismiss pending clarifying questions
+ description: v1.16.0 — dismisses one question (body `{ question }`, exact match) or all of them (empty body). Tapping a
+ chip in the Coach composer inserts the question into the chat input and dismisses it here.
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ question:
+ description: Exact question text to dismiss. Omitted = dismiss all.
+ type: string
+ responses:
+ "200":
+ description: The remaining questions after the dismissal.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteCoachAboutMeQuestionsResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/admin/invites:
+ get:
+ tags:
+ - Admin
+ summary: List registration invites
+ description: v1.16.0 — every invite with creator / consumer usernames, use counters, soft-revocation timestamp, and the
+ full per-signup redemption ledger (`redemptions`; `consumer` only carries the LAST account on a multi-use
+ invite). Metadata only — the raw token is never derivable from this endpoint (only its HMAC hash is persisted).
+ Admin session cookie required; Bearer tokens cannot reach admin endpoints.
+ responses:
+ "200":
+ description: All invites, newest first.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminInviteList"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ post:
+ tags:
+ - Admin
+ summary: Mint a registration invite
+ description: v1.15.20 — creates an invite that admits a signup even while open registration is disabled. The raw
+ `hlv_<64hex>` token and the composed registration URL appear EXACTLY ONCE in this response; only the keyed hash
+ is persisted. Lifetime is capped at 30 days; `maxUses` makes multi-use invites possible (consumption is an
+ atomic guarded increment). Admin session cookie required.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ expiresInDays:
+ default: 7
+ type: integer
+ minimum: 1
+ maximum: 30
+ maxUses:
+ default: 1
+ type: integer
+ minimum: 1
+ maximum: 50
+ responses:
+ "201":
+ description: The minted invite, including the one-time raw token.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminInviteCreated"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/admin/invites/{id}:
+ delete:
+ tags:
+ - Admin
+ summary: Revoke a registration invite
+ description: "v1.16.0 — soft-revokes the invite (`revokedAt`): the row keeps its redemption history visible in the admin
+ table while the consume path refuses the link like an expired one. Idempotent: an unknown or already-revoked id
+ returns `{ revoked: false }` instead of 404. Admin session cookie required."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: "The invite was revoked (`revoked: true`) or nothing matched (`revoked: false`)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminInviteRevoked"
+ "401": *a1
+ "422": *a3
+ "429": *a4
/api/insights/chat/messages/{id}/feedback:
post:
tags:
@@ -4615,6 +4796,13 @@ components:
type: string
minLength: 1
maxLength: 64
+ timeFormat:
+ description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
+ type: string
+ enum:
+ - AUTO
+ - H12
+ - H24
moodReminderEnabled:
type: boolean
fullName:
@@ -10022,6 +10210,13 @@ components:
- type: "null"
timezone:
type: string
+ timeFormat:
+ type: string
+ enum:
+ - AUTO
+ - H12
+ - H24
+ description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
moodReminderEnabled:
type: boolean
fullName:
@@ -10050,6 +10245,7 @@ components:
- heightCm
- locale
- timezone
+ - timeFormat
- moodReminderEnabled
- fullName
- insurerName
@@ -10111,6 +10307,13 @@ components:
- type: "null"
timezone:
type: string
+ timeFormat:
+ type: string
+ enum:
+ - AUTO
+ - H12
+ - H24
+ description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
moodReminderEnabled:
type: boolean
fullName:
@@ -10137,6 +10340,7 @@ components:
- heightCm
- locale
- timezone
+ - timeFormat
- moodReminderEnabled
- fullName
- insurerName
@@ -10304,6 +10508,374 @@ components:
- data
- error
additionalProperties: false
+ GetCoachAboutMeResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ aboutMe:
+ anyOf:
+ - type: string
+ - type: "null"
+ conditions:
+ anyOf:
+ - type: string
+ - type: "null"
+ allergies:
+ anyOf:
+ - type: string
+ - type: "null"
+ coachFocus:
+ anyOf:
+ - type: string
+ - type: "null"
+ pendingQuestions:
+ type: array
+ items:
+ type: string
+ updatedAt:
+ 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"
+ maxChars:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ fieldMaxChars:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - aboutMe
+ - conditions
+ - allergies
+ - coachFocus
+ - pendingQuestions
+ - updatedAt
+ - maxChars
+ - fieldMaxChars
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ PutCoachAboutMeResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ aboutMe:
+ anyOf:
+ - type: string
+ - type: "null"
+ conditions:
+ anyOf:
+ - type: string
+ - type: "null"
+ allergies:
+ anyOf:
+ - type: string
+ - type: "null"
+ coachFocus:
+ anyOf:
+ - type: string
+ - type: "null"
+ pendingQuestions:
+ type: array
+ items:
+ type: string
+ updatedAt:
+ 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)))$
+ maxChars:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ fieldMaxChars:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - aboutMe
+ - conditions
+ - allergies
+ - coachFocus
+ - pendingQuestions
+ - updatedAt
+ - maxChars
+ - fieldMaxChars
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GetCoachAboutMeQuestionsResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ questions:
+ type: array
+ items:
+ type: string
+ required:
+ - questions
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DeleteCoachAboutMeQuestionsResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ questions:
+ type: array
+ items:
+ type: string
+ required:
+ - questions
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ AdminInviteList:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ createdAt:
+ 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)))$
+ expiresAt:
+ 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)))$
+ usedAt:
+ 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"
+ revokedAt:
+ 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"
+ uses:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ maxUses:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ creator:
+ anyOf:
+ - type: object
+ properties:
+ id:
+ type: string
+ username:
+ type: string
+ required:
+ - id
+ - username
+ additionalProperties: false
+ - type: "null"
+ consumer:
+ anyOf:
+ - type: object
+ properties:
+ id:
+ type: string
+ username:
+ type: string
+ required:
+ - id
+ - username
+ additionalProperties: false
+ - type: "null"
+ redemptions:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ redeemedAt:
+ 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)))$
+ user:
+ anyOf:
+ - type: object
+ properties:
+ id:
+ type: string
+ username:
+ type: string
+ email:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - id
+ - username
+ - email
+ additionalProperties: false
+ - type: "null"
+ required:
+ - id
+ - redeemedAt
+ - user
+ additionalProperties: false
+ required:
+ - id
+ - createdAt
+ - expiresAt
+ - usedAt
+ - revokedAt
+ - uses
+ - maxUses
+ - creator
+ - consumer
+ - redemptions
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ AdminInviteCreated:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ id:
+ type: string
+ createdAt:
+ 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)))$
+ expiresAt:
+ 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)))$
+ uses:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ maxUses:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ token:
+ type: string
+ description: Raw invite token (`hlv_<64hex>`). Shown exactly once — never persisted in plaintext.
+ url:
+ type: string
+ description: Composed registration deep link (`/auth/register?invite=…`).
+ required:
+ - id
+ - createdAt
+ - expiresAt
+ - uses
+ - maxUses
+ - token
+ - url
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ AdminInviteRevoked:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ revoked:
+ type: boolean
+ required:
+ - revoked
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
CoachMessageFeedbackResponse:
type: object
properties:
@@ -10668,10 +11240,16 @@ components:
- ready
- preparing
- disabled
+ - no-provider
briefingUpdatedAt:
anyOf:
- type: string
- type: "null"
+ briefingStale:
+ type: boolean
+ description: True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`)
+ or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a
+ blank tile.
generatedAt:
type: string
required:
@@ -10684,15 +11262,18 @@ components:
- briefing
- briefingState
- briefingUpdatedAt
+ - briefingStale
- generatedAt
additionalProperties: false
- description: Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget
+ description: "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget
layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never
waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated
- synchronously — and reports `ready` / `preparing` / `disabled` via `briefingState`. `layoutCatalogue` (full
- 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are
- additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB
- round-trip.
+ synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState`
+ (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it;
+ stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with
+ `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per
+ metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive
+ in-process from data already fetched, adding no DB round-trip."
InsightsComprehensiveResponseEnvelope:
type: object
properties:
diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md
index c7c9e7099..1c09be209 100644
--- a/docs/ops/intake-repair.md
+++ b/docs/ops/intake-repair.md
@@ -31,15 +31,24 @@ the medication intake ledger (`medication_intake_events`):
Always. The default mode only reports and never mutates:
```bash
-# inside the app container (or any checkout with DATABASE_URL set)
-pnpm dlx tsx scripts/repair-intake-anomalies.ts
+# inside the app container (or any checkout) — DATABASE_URL must point
+# at the target database
+pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
# scoped to one account
-pnpm dlx tsx scripts/repair-intake-anomalies.ts --user
+pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user
```
-Use `pnpm dlx tsx`, not bare `pnpm tsx` — the production standalone image
-strips `tsx`. `DATABASE_URL` must point at the target database.
+The script is self-contained: its only import is `pg` and it reads
+`DATABASE_URL` straight from the environment (no dotenv, no `@/` path
+alias, no `src/` imports). That matters in the production container —
+the standalone image ships no project `node_modules`, so the previous
+revision died there with `Cannot find module 'dotenv/config'`. Inside
+the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a
+checkout it resolves from the project `node_modules`; the
+`--package pg --package tsx` pins cover any environment with neither.
+Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
+`tsx`.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned) and a table of implausible rows (id, medication,
@@ -48,7 +57,7 @@ would be tombstoned) and a table of implausible rows (id, medication,
## Applying the fix
```bash
-pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix
+pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
```
`--fix` does three things:
@@ -66,13 +75,17 @@ pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix
in explicitly:
```bash
- pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
+ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
```
`--tombstone-implausible` is refused without `--fix`.
- **Affected compliance rollups are recomputed** for every touched
- `(user, medication, day)` through the shared rollup helper, so the
- scheduled/taken counts and the rate self-correct immediately.
+ `(user, medication, day)`. The script executes the same DISTINCT-slot
+ aggregation SQL the shared helper runs (a verbatim twin of
+ `recomputeMedicationComplianceForDay` in
+ `src/lib/rollups/medication-compliance-rollups.ts` — kept inline so
+ the script needs no app imports), so the scheduled/taken counts and
+ the rate self-correct immediately.
The script is idempotent: a second `--fix` run finds zero duplicate groups
and changes nothing. Exit code is 0 on success (including a clean
diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md
index 224a8c21d..1afa09ad3 100644
--- a/docs/ui-guidelines.md
+++ b/docs/ui-guidelines.md
@@ -48,7 +48,7 @@ Tailwind's default 4-pixel scale. Reach for these stops first:
| ------------------------------------ | ------------- | ------ |
| Inline gap between icon and label | `gap-1` | 4 |
| Tight stack inside a card | `gap-2` | 8 |
-| Default form-field stack | `space-y-3` | 12 |
+| Label-to-field stack (form fields) | `space-y-2` | 8 |
| Section block (heading + body) | `space-y-4` | 16 |
| Page section separator | `space-y-6` | 24 |
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
@@ -65,13 +65,18 @@ these classes first:
| Use | Class |
| ---------------------------------- | --------------------------------------- |
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
-| Section heading (h2) | `text-lg font-semibold` |
-| Card title | `text-base font-medium` |
+| Section heading / section card | `text-lg font-semibold` |
+| Tile / inner card title | `text-base font-semibold` |
| Body text | `text-sm` |
| Muted helper / metadata | `text-sm text-muted-foreground` |
| Microcopy / footer | `text-xs text-muted-foreground` |
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
+Card titles come in exactly two sizes: `text-lg font-semibold` for a
+section-level card (one per page region) and `text-base font-semibold`
+for tiles and inner cards. Don't introduce a third level — a smaller
+heading is a sign the content wants a list row, not a card.
+
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
let the design system breathe.
@@ -381,7 +386,16 @@ We aim for **WCAG 2.1 AA**, with the following non-negotiables:
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. Verified by `@axe-core/playwright` in CI on every page.
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
- (WCAG 2.5.5).
+ (WCAG 2.5.5). Two idioms implement the floor, and they are deliberate:
+ - **Form controls** (`Input`, `NativeSelect`, `SelectTrigger` default
+ size): `h-11 sm:h-10` is baked into the primitives — 44 px on
+ mobile, back to the 40 px desktop tier. Never re-add `min-h-11` on
+ top of these; it forces 44 px on desktop and breaks the row rhythm.
+ - **Buttons on dense touch surfaces** (medication detail, dashboard
+ quick actions): `min-h-11 sm:min-h-9` — 44 px mobile floor,
+ compact `sm` tier on desktop. Plain `min-h-11` without the `sm:`
+ reset is legacy spelling; don't introduce new ones, and align a
+ button to its direct neighbours when editing one.
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
every customized component.
- Every interactive element has an accessible name. Icon-only buttons
diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts
index 29043ee61..6bfdd2954 100644
--- a/e2e/utils/mock-dashboard-snapshot.ts
+++ b/e2e/utils/mock-dashboard-snapshot.ts
@@ -147,6 +147,7 @@ export function buildMockSnapshot(
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
+ briefingStale: false,
generatedAt: now,
};
}
diff --git a/messages/de.json b/messages/de.json
index 430d15ebc..0703401ba 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -58,11 +58,11 @@
"classification": "Klassifikation",
"bmiTitle": "Body-Mass-Index (BMI)",
"bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)",
- "complianceTitle": "Medikamenten-Compliance",
+ "complianceTitle": "Therapietreue",
"glp1Title": "GLP-1-Therapie",
"glp1WeightSummary": "Gewicht im Berichtszeitraum: {start} kg → {end} kg ({delta} kg)",
"glp1CurrentDose": "Aktuelle Dosis: {value} {unit} (seit {since})",
- "glp1Compliance": "Compliance: {taken} / {total} ({rate}%)",
+ "glp1Compliance": "Therapietreue: {taken} / {total} ({rate}%)",
"glp1SideEffectsTitle": "Erfasste Nebenwirkungen",
"colGlp1Date": "Datum",
"colGlp1Dose": "Dosis",
@@ -73,7 +73,7 @@
"colSkipped": "Übersprungen",
"colMissed": "Verpasst",
"colTotal": "Gesamt",
- "colComplianceRate": "Compliance-Rate",
+ "colComplianceRate": "Therapietreue",
"moodTitle": "Stimmungsverlauf",
"moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}",
"colMood": "Stimmung",
@@ -144,7 +144,7 @@
"bmi": "Body-Mass-Index (BMI)",
"mood": "Stimmung",
"moodSensitive": "Sensibel — standardmäßig aus.",
- "compliance": "Medikamenten-Compliance",
+ "compliance": "Therapietreue",
"sleep": "Schlafdauer",
"cycle": "Menstruationszyklus",
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
@@ -155,7 +155,7 @@
"summaryTitle": "Klinische Zusammenfassung",
"summaryReadings": "Über {days} Tage erfasst: {count} Messwerte über {params} Parameter.",
"summaryTrend": "{label}: zuletzt {latest} {arrow}",
- "summaryAdherence": "Medikamenten-Einnahmetreue: {rate}% (gewichteter Mittelwert).",
+ "summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).",
"chartsTitle": "Trend-Diagramme",
"aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
"aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
@@ -192,6 +192,9 @@
"offlineBanner": {
"message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist."
},
+ "demoBanner": {
+ "message": "Demo-Modus — Änderungen werden nicht gespeichert."
+ },
"nav": {
"dashboard": "Dashboard",
"measurements": "Messungen",
@@ -251,6 +254,7 @@
"hasAccount": "Bereits ein Konto?",
"loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen",
+ "invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.",
"createAccount": "Konto erstellen",
"newAccount": "Neues Konto erstellen",
"backToPasskey": "Zurück zu Passkey",
@@ -314,7 +318,7 @@
"confirm": "Verwerfen",
"cancel": "Weiter bearbeiten"
},
- "compliance7d": "Einnahmetreue (7 Tage)",
+ "compliance7d": "Therapietreue (7 Tage)",
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"layoutReset": "Auf Defaults zurücksetzen",
@@ -639,14 +643,14 @@
"slotNone": "Kein bestimmter Slot",
"takenAtLabel": "Datum und Uhrzeit",
"takenAtHint": "Standard ist jetzt; wähle ein früheres Datum und eine frühere Uhrzeit, um eine vergangene Dosis zu erfassen.",
- "skippedLabel": "Als ausgelassen erfassen",
+ "skippedLabel": "Als übersprungen erfassen",
"noMedications": "Lege zuerst ein Medikament an, dann kannst du Einnahmen dafür erfassen.",
"cancel": "Abbrechen",
"submit": "Einnahme erfassen",
"slotError": "Der gewählte Slot konnte keiner Uhrzeit zugeordnet werden. Bitte wähle einen anderen Slot oder „Kein bestimmter Slot“."
},
"emptyTitle": "Noch keine Medikamente",
- "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um Compliance zu erfassen.",
+ "emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um die Therapietreue zu erfassen.",
"loadFailed": "Medikamente konnten nicht geladen werden",
"retryLoad": "Erneut laden",
"name": "Name",
@@ -660,7 +664,7 @@
"intakeHistory": "Einnahme-Verlauf",
"openDetailPage": "Medikament-Detailseite öffnen",
"deleteConfirm": "Medikament löschen?",
- "compliance": "Einnahmetreue",
+ "compliance": "Therapietreue",
"importIntakes": "Einnahmen importieren",
"importDescription": "JSON-Array mit Einnahme-Daten einfügen. Erwartetes Format:",
"importUploadFile": "JSON-Datei hochladen",
@@ -716,9 +720,11 @@
"takeNow": "Jetzt einnehmen",
"overdue": "Überfällig",
"veryOverdue": "Stark überfällig",
- "compliance7d": "7-Tage-Compliance",
- "compliance30d": "30-Tage-Compliance",
- "complianceWindow": "{days}-Tage-Compliance",
+ "compliance7d": "Therapietreue (7 Tage)",
+ "compliance30d": "Therapietreue (30 Tage)",
+ "complianceWindow": "Therapietreue ({days} Tage)",
+ "complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.",
+ "complianceInfoLabel": "Was bedeutet Therapietreue?",
"complianceDoses": "{count} Dosen",
"lastIntake": "Letzte Einnahme:",
"nextIntake": "Nächste Einnahme:",
@@ -834,7 +840,7 @@
"title": "Medikament beschreiben",
"description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.",
"placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag",
- "submit": "Übernehmen",
+ "submit": "Extrahieren",
"submitBusy": "Wird verarbeitet …",
"cancel": "Abbrechen",
"examples": {
@@ -1046,6 +1052,8 @@
"subline": "Du kannst alles unten nochmal prüfen, bevor du speicherst.",
"remindersLabel": "Erinnerungen aktiv",
"remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.",
+ "noChannelHint": "Erinnerungen brauchen einen Benachrichtigungskanal — aktuell ist keiner aktiv.",
+ "noChannelHintLink": "Benachrichtigungen einrichten",
"multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version.",
"short": "Fertig"
}
@@ -1282,19 +1290,6 @@
"paused": "Pausiert",
"ended": "Beendet"
},
- "today": {
- "groupLabel": "Heutige Dosis",
- "taken": "Genommen",
- "skipped": "Übersprungen",
- "toastTaken": "Heutige Dosis erfasst",
- "toastSkipped": "Heutige Dosis übersprungen",
- "recordedTaken": "Heute genommen um {time}",
- "recordedSkipped": "Heute übersprungen um {time}",
- "recordedOneShot": "Einmalig genommen um {time}",
- "noneScheduled": "Heute keine Einnahme geplant.",
- "pausedHint": "Pausiert — heute keine Erinnerung.",
- "error": "Dosis konnte nicht erfasst werden."
- },
"cadence": {
"oneShotOn": "Einmalige Dosis am {date}.",
"oneShotPending": "Einmalige Dosis ausstehend."
@@ -1349,6 +1344,9 @@
"add": "Eintrag",
"markTaken": "Genommen",
"markSkipped": "Übersprungen",
+ "rowActionsLabel": "Aktionen für {time}",
+ "markAsTaken": "Als genommen markieren",
+ "markAsSkipped": "Als übersprungen markieren",
"adHocTag": "außerplanmäßig",
"takenDetail": "geplant {planned} · genommen {taken}",
"status": {
@@ -1373,6 +1371,18 @@
"body": "Die Einnahme liegt knapp außerhalb des Fensters von {slot}. Diesem Slot zuordnen oder als außerplanmäßigen Eintrag speichern?",
"keepAdHoc": "Außerplanmäßig",
"pin": "Zuordnen"
+ },
+ "pin": {
+ "action": "Slot {time} zuordnen",
+ "release": "Zuordnung lösen",
+ "pinnedTag": "zugeordnet",
+ "pinnedToast": "Einnahme dem Slot zugeordnet",
+ "unpinnedToast": "Zuordnung gelöst",
+ "dueContext": "fällig gewesen: {time} ({delta})",
+ "confirmTitle": "Slot {time} zuordnen?",
+ "confirmBody": "Die Einnahme zählt für diesen Slot als verspätet – die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
+ "confirmCancel": "Abbrechen",
+ "confirmAction": "Zuordnen"
}
},
"notifications": {
@@ -1392,8 +1402,8 @@
"requiresCourseWindow": "Phasen sind nur mit Kurs-Fenster verfügbar."
},
"grace": {
- "label": "Erinnerungsfenster — gilt für deinen Hauptzeitplan",
- "primaryScheduleNote": "Andere Zeitpläne behalten ihr Standard-Fenster.",
+ "label": "Erinnerungsfenster",
+ "appliesToAllNote": "Gilt für alle Zeitpläne dieses Medikaments.",
"unit": "Minuten",
"saved": "Erinnerungsfenster gespeichert",
"failed": "Erinnerungsfenster konnte nicht gespeichert werden."
@@ -1468,7 +1478,6 @@
"title": "Erweiterte Einstellungen",
"group": {
"data": "Daten",
- "reminders": "Erinnerungen",
"lifecycle": "Lebenszyklus",
"externalApi": "Externe API"
},
@@ -1487,8 +1496,7 @@
}
},
"header": {
- "historyLabel": "Einnahmeverlauf ansehen",
- "advancedLabel": "Erweiterte Einstellungen"
+ "historyLabel": "Einnahmeverlauf ansehen"
},
"history": {
"back": "Zurück zum Medikament",
@@ -1498,7 +1506,6 @@
"tabs": {
"uebersicht": "Übersicht",
"zeitplan": "Zeitplan",
- "erinnerung": "Erinnerung",
"bestand": "Bestand",
"verlauf": "Verlauf",
"injektion": "Injektion",
@@ -1510,6 +1517,17 @@
"tablistLabel": "Medikamenten-Detailbereiche"
},
"uebersicht": {
+ "statusTitle": "Status",
+ "nextDoseLabel": "Nächste Dosis",
+ "nextDoseNone": "Keine anstehende Dosis.",
+ "pausedHint": "Pausiert — keine Erinnerungen.",
+ "reminderLabel": "Erinnerung",
+ "reminderOn": "Aktiv",
+ "reminderOff": "Aus",
+ "supplyLabel": "Bestand",
+ "supplyRunway": "Reicht noch ca. {days} Tage",
+ "jumpToZeitplan": "Zum Zeitplan",
+ "jumpToBestand": "Zum Bestand",
"complianceTitle": "Therapietreue",
"complianceEmpty": "Noch keine Daten zur Therapietreue."
},
@@ -1542,7 +1560,7 @@
"scheduleLabel": "Zeitplan {index}",
"fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du über „Vollständig bearbeiten“.",
"graceEcho": "Erinnerungsfenster: {minutes} Minuten",
- "graceLink": "In „Erinnerung“ ändern",
+ "reminderTitle": "Erinnerung",
"save": "Zeitplan speichern",
"saved": "Zeitplan gespeichert",
"failed": "Zeitplan konnte nicht gespeichert werden",
@@ -1573,6 +1591,7 @@
"charts": {
"loadingLabel": "Diagramm wird geladen…",
"loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.",
+ "adjustTargets": "Zielwerte anpassen",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
"pulse": "Puls",
@@ -1697,7 +1716,7 @@
"lastMonth": "Vergleich nicht verfügbar — noch keine Daten aus dem Vormonat",
"lastYear": "Vergleich nicht verfügbar — noch keine Daten aus dem Vorjahr"
},
- "notAvailableForCompliance": "Vergleich für Compliance nicht verfügbar"
+ "notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
},
"insights": {
"emptyTitle": "Noch nicht genug Daten",
@@ -1712,8 +1731,7 @@
"dragHandle": "Zum Sortieren ziehen",
"show": "Einblenden",
"hide": "Ausblenden",
- "manageSubPages": "Detailseiten verwalten",
- "manageSubPagesHint": "Ausgeblendete Seiten verschwinden auch aus der oberen Navigation.",
+ "manageInSettings": "Pills & Detailseiten in den Einstellungen verwalten",
"groupSleep": "Schlaf",
"groupMood": "Stimmung",
"groupEvents": "Ereignisse",
@@ -1806,13 +1824,13 @@
"componentBp": "Blutdruck",
"componentWeight": "Gewicht",
"componentMood": "Stimmung",
- "componentCompliance": "Einnahmetreue",
+ "componentCompliance": "Therapietreue",
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
"deltaExplainer": {
"trigger": "Was bedeutet diese Veränderung?",
"title": "So wird die Veränderung berechnet",
"description": "Veränderung gegenüber letzter Woche: {delta}",
- "body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Einnahmetreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen."
+ "body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Therapietreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen."
},
"provenance": {
"toggle": "Zusammensetzung",
@@ -1843,7 +1861,10 @@
"emptyTitle": "Noch kein Briefing",
"emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.",
"emptyAction": "Briefing erstellen",
- "loadingLabel": "Briefing wird zusammengestellt"
+ "loadingLabel": "Briefing wird zusammengestellt",
+ "noProviderTitle": "Kein KI-Anbieter verbunden",
+ "noProviderDescription": "Verbinde einen KI-Anbieter in den Einstellungen, um tägliche Briefings zu erhalten.",
+ "noProviderAction": "Zu den KI-Einstellungen"
},
"trendsRow": {
"title": "Trends",
@@ -1911,8 +1932,8 @@
"confidenceModerate": "Mittlere Konfidenz",
"confidenceLow": "Geringe Konfidenz",
"card": {
- "bpComplianceTitle": "BD × Medikamenten-Compliance",
- "bpComplianceSubtitle": "Täglicher Systolischer gegen Compliance-%",
+ "bpComplianceTitle": "BD × Therapietreue",
+ "bpComplianceSubtitle": "Täglicher Systolischer gegen Therapietreue in %",
"moodPulseTitle": "Stimmung × Ruhepuls",
"moodPulseSubtitle": "Stimmung und Ruhepuls am selben Tag",
"weightWeekdayTitle": "Gewicht × Wochentag",
@@ -1936,6 +1957,8 @@
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
"composerHint": "Enter zum Senden, Umschalt+Enter für eine neue Zeile.",
"composerDisclaimer": "Coach-Antworten sind generiert. Klinische Entscheidungen gehören in die Hand deines Arztes.",
+ "selfContextChipsLabel": "Der Coach würde gern wissen:",
+ "selfContextChipDismiss": "Frage verwerfen",
"voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.",
"threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.",
"historyTitle": "Unterhaltungen",
@@ -1953,7 +1976,7 @@
"weight": "Gewicht",
"pulse": "Puls",
"mood": "Stimmung",
- "compliance": "Einnahmetreue",
+ "compliance": "Therapietreue",
"general": "Allgemein",
"hrv": "HRV",
"sleep": "Schlaf",
@@ -2099,6 +2122,8 @@
"navSteps": "Schritte",
"navActiveEnergy": "Aktive Energie",
"navAriaLabel": "Zu Abschnitt springen",
+ "pillScrollLeft": "Tabs nach links scrollen",
+ "pillScrollRight": "Tabs nach rechts scrollen",
"tabStrip": {
"vitalsParent": {
"label": "Vitalwerte",
@@ -2218,13 +2243,13 @@
"weightTitle": "Gewicht",
"weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.",
"bmiTitle": "Body-Mass-Index (BMI)",
- "bmiBody": "Der BMI ist das Gewicht geteilt durch die Körpergröße zum Quadrat und ordnet Sie einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.",
+ "bmiBody": "Der BMI ist dein Gewicht geteilt durch deine Körpergröße zum Quadrat und ordnet dich einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.",
"sleepTitle": "Schlaf",
"sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.",
"moodTitle": "Stimmung",
- "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie Sie sich fühlen. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
+ "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
"medicationsTitle": "Medikamenten-Therapietreue",
- "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig Sie Ihre Medikamente wie verordnet einnehmen, hier als Anteil der erwarteten Dosen, die Sie erfasst haben. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.",
+ "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig du deine Medikamente wie verordnet einnimmst, hier als Anteil der erwarteten Dosen, die du erfasst hast. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.",
"restingHrTitle": "Ruhepuls",
"restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.",
"hrvTitle": "Herzfrequenzvariabilität (HRV)",
@@ -2232,11 +2257,11 @@
"oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)",
"oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.",
"bodyTemperatureTitle": "Körpertemperatur",
- "bodyTemperatureBody": "Die Körpertemperatur spiegelt Ihren Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.",
+ "bodyTemperatureBody": "Die Körpertemperatur spiegelt deinen Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.",
"activeEnergyTitle": "Aktive Energie",
- "activeEnergyBody": "Die aktive Energie sind die Kalorien, die Sie durch Bewegung verbrennen, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv Ihr Tag war.",
+ "activeEnergyBody": "Die aktive Energie sind die Kalorien, die du durch Bewegung verbrennst, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv dein Tag war.",
"workoutsTitle": "Workouts",
- "workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die Ihre Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit Ihren anderen Gesundheitssignalen zusammenhängt.",
+ "workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die deine Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit deinen anderen Gesundheitssignalen zusammenhängt.",
"respiratoryRateTitle": "Atemfrequenz",
"respiratoryRateBody": "Die Atemfrequenz ist die Anzahl der Atemzüge pro Minute. In Ruhe atmen die meisten Erwachsenen etwa zwölf- bis zwanzigmal pro Minute; ein anhaltender Anstieg kann mit Krankheit, Anstrengung oder Stress einhergehen.",
"bodyWaterTitle": "Gesamtkörperwasser",
@@ -2301,11 +2326,11 @@
"stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
"breathingDisturbancesTitle": "Atemaussetzer im Schlaf",
"breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.",
- "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert bedeutet, dass dein System erholt und belastbar wirkt; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten und deinem Körper Zeit zum Aufholen zu geben. Sieh ihn als Orientierung für die Tagesplanung, nicht als Urteil.",
- "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen und wägt ab, wie lange und wie effizient du geschlafen hast, wie regelmäßig deine Zeiten waren und wie sich Leicht-, Tief- und REM-Schlaf verteilen. Er soll auf einen Blick zeigen, ob die Nacht dich wirklich erholt hat. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.",
- "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat und wie viel Kapazität du heute für Belastung hast — sie stützt sich auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut. Er ist eine Orientierung, wie stark du dich einbringen kannst, keine starre Grenze.",
- "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen und spiegelt so, was speziell für dich fordernd ist. Nutze sie, um anstrengende Tage mit ruhigeren auszugleichen und dein Training tragfähig zu halten.",
- "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigeren, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen. Er ist ein beschreibender Näherungswert, um Trends zu erkennen, keine klinische Messung."
+ "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.",
+ "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.",
+ "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
+ "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen, nicht an einem fremden Maßstab.",
+ "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen."
},
"stats": {
"label": "Zusammenfassende Statistik",
@@ -2359,10 +2384,10 @@
}
},
"correlations": "Korrelationen",
- "medicationCompliance": "Medikamenten-Compliance",
+ "medicationCompliance": "Therapietreue",
"dayStreak": "{count} Tage Serie",
- "compliance7d": "7-Tage-Compliance",
- "compliance30d": "30-Tage-Compliance",
+ "compliance7d": "Therapietreue (7 Tage)",
+ "compliance30d": "Therapietreue (30 Tage)",
"taken": "genommen",
"skipped": "übersprungen",
"missed": "verpasst",
@@ -2378,7 +2403,7 @@
"weightSectionTitle": "Gewicht",
"pulseSectionTitle": "Puls",
"bmiSectionTitle": "BMI",
- "complianceNoData": "Keine Compliance-Daten verfügbar",
+ "complianceNoData": "Keine Daten zur Therapietreue verfügbar",
"generate": "Neue Analyse erstellen",
"moodSectionTitle": "Stimmung",
"confidence": "Konfidenz",
@@ -2411,7 +2436,7 @@
},
"medication": {
"title": "Noch keine Medikamente angelegt",
- "description": "Lege ein Medikament an, dann erscheinen Einnahme-Compliance, Wirkstoffspiegel und Eskalations-Verlauf.",
+ "description": "Lege ein Medikament an, dann erscheinen Therapietreue, Wirkstoffspiegel und Eskalations-Verlauf.",
"cta": "Medikament hinzufügen"
},
"sleep": {
@@ -3148,7 +3173,7 @@
},
"pillOrder": {
"title": "Navigations-Pills sortieren",
- "description": "Ziehe die Pills in die Reihenfolge, in der sie oben in den Insights erscheinen sollen.",
+ "description": "Ziehe die Pills in die gewünschte Reihenfolge und blende sie über das Augensymbol ein oder aus. Ausgeblendete Detailseiten verschwinden auch aus der oberen Navigation.",
"dragHandle": "Verschieben",
"saveSuccess": "Reihenfolge der Pills gespeichert.",
"saveError": "Speichern der Pill-Reihenfolge fehlgeschlagen.",
@@ -3156,6 +3181,8 @@
"empty": "Noch keine Navigations-Pills verfügbar."
},
"customize": "Insights anpassen",
+ "regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.",
+ "regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.",
"regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen."
},
"thresholds": {
@@ -3327,7 +3354,7 @@
"measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag.",
"measurementCta": "Messung hinzufügen",
"medicationTitle": "Medikament hinzufügen",
- "medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
+ "medicationDescription": "Verfolge die Therapietreue und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
"medicationCta": "Medikament hinzufügen",
"withingsTitle": "Withings verbinden (optional)",
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
@@ -3382,6 +3409,8 @@
"sources": {
"title": "Quellen-Priorität",
"description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.",
+ "integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.",
+ "integrationsHintLink": "Integrationen öffnen",
"cardTitle": "Quellen-Priorität pro Metrik",
"help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.",
"resetDefaults": "Auf Standard zurücksetzen",
@@ -3455,7 +3484,7 @@
"downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.",
"hero": {
"eyebrow": "Arzttermin",
- "valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Medikamenten-Compliance und (optional) Stimmungsverlauf, alles auf wenigen Seiten.",
+ "valueStatement": "Ein druckbarer PDF-Bericht für den Arzttermin — Vitalwerte, BMI, Blutdruck-Klassifikation, Therapietreue und (optional) Stimmungsverlauf, alles auf wenigen Seiten.",
"cta": "Bericht konfigurieren & generieren",
"formatHint": "PDF · druckfertig"
},
@@ -3469,7 +3498,7 @@
"cards": {
"doctorReport": {
"title": "Arztbericht",
- "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Medikamenten-Compliance, Stimmung."
+ "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Therapietreue, Stimmung."
},
"measurementsCsv": {
"title": "Messwerte",
@@ -3553,6 +3582,15 @@
"profile": "Profil",
"language": "Sprache",
"languageDescription": "Wähle die Anzeigesprache der Anwendung.",
+ "timeFormat": {
+ "title": "Stundenformat",
+ "description": "Bestimmt, ob Uhrzeiten im 24-Stunden- oder 12-Stunden-Format (AM/PM) angezeigt werden.",
+ "auto": "Automatisch (Sprache)",
+ "h24": "24 Stunden",
+ "h12": "12 Stunden (AM/PM)",
+ "saved": "Stundenformat gespeichert.",
+ "saveError": "Speichern des Stundenformats fehlgeschlagen."
+ },
"username": "Benutzername",
"height": "Körpergröße (cm)",
"dateOfBirth": "Geburtsdatum",
@@ -3642,8 +3680,8 @@
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
"rawData": "Rohdaten mitsenden",
- "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit der Provider zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
- "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, der Provider kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
+ "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte einzelne Messpunkte der letzten 30 Tage mit Zeitbezug gesendet — ohne Name, E-Mail oder direkte Kontodaten.",
+ "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum) — ohne einzelne Messpunkte und exakte Zeitbezüge.",
"rawDataWarning": "Rohdaten aktiv: Der Provider erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.",
"regenerateInsights": "Reports neu generieren",
"regenerateSuccess": "Reports wurden neu generiert",
@@ -3775,6 +3813,30 @@
"savedHidden": "Coach ist ausgeblendet.",
"savedShown": "Coach ist sichtbar.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
+ },
+ "aboutMe": {
+ "title": "Über mich",
+ "description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.",
+ "placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon …",
+ "charCount": "{used} / {max} Zeichen",
+ "clear": "Leeren",
+ "save": "Speichern",
+ "savedToast": "Über-mich-Notiz gespeichert.",
+ "clearedToast": "Über-mich-Notiz gelöscht.",
+ "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut.",
+ "loadError": "Die Notiz konnte nicht geladen werden.",
+ "hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden.",
+ "profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.",
+ "freeTextLabel": "Über mich (Freitext)",
+ "conditionsLabel": "Chronische Erkrankungen",
+ "conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes …",
+ "allergiesLabel": "Allergien & Unverträglichkeiten",
+ "allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie …",
+ "focusLabel": "Worauf soll der Coach achten?",
+ "focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung …",
+ "completeness": "{answered} von {total} beantwortet",
+ "questionsTitle": "Der Coach hat Rückfragen",
+ "questionsOpenCoach": "Im Coach beantworten"
}
},
"dashboard": {
@@ -3804,6 +3866,21 @@
"withingsSyncResult": "{count} Messwerte synchronisiert",
"withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert",
"withingsSyncFailed": "Sync fehlgeschlagen",
+ "withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.",
+ "withingsOauthFailed": "Withings-Verbindung fehlgeschlagen",
+ "withingsOauthError": {
+ "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Starte die Verbindung bitte erneut im selben Browser.",
+ "replay": "Dieser Bestätigungslink wurde bereits verwendet. Starte die Verbindung bitte erneut.",
+ "state": "Die Antwort von Withings passte zu keiner offenen Verbindungsanfrage. Starte die Verbindung bitte erneut.",
+ "expired": "Die Verbindungsanfrage ist abgelaufen. Starte die Verbindung erneut und schließe die Freigabe innerhalb weniger Minuten ab.",
+ "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Starte die Verbindung bitte aus deinem eigenen Konto neu.",
+ "nocode": "Withings hat keinen Freigabe-Code übermittelt. Versuche es erneut und erlaube den Zugriff im Withings-Dialog.",
+ "nocreds": "Es sind keine Withings-API-Zugangsdaten hinterlegt. Speichere zuerst Client-ID und Client-Secret, dann verbinde erneut.",
+ "token": "Der Token-Austausch mit Withings ist fehlgeschlagen. Prüfe Client-ID und Client-Secret und versuche es erneut.",
+ "generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut."
+ },
+ "integrationsSourcesHint": "Liefern mehrere Quellen dieselbe Metrik, entscheidet die Quellen-Priorität, welcher Wert zählt.",
+ "integrationsSourcesHintLink": "Quellen-Priorität öffnen",
"withingsSynchronize": "Synchronisieren",
"withingsDisconnect": "Trennen",
"withingsDisconnectTitle": "Withings-Verbindung trennen?",
@@ -4049,7 +4126,7 @@
"distance": "Distanz",
"sleep": "Schlaf",
"glucose": "Blutzucker",
- "medications": "Medikamente & Einnahmetreue",
+ "medications": "Medikamente & Therapietreue",
"mood": "Stimmung (standardmäßig aus — sensible Daten)",
"bmi": "BMI",
"includeCharts": "Trend-Diagramme einbetten (nur PDF)",
@@ -4240,6 +4317,55 @@
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Issue-Token",
"bugReportTokenPlaceholder": "ghp_...",
+ "invites": {
+ "title": "Einladungen",
+ "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.",
+ "adminOnlyHint": "Nur Admins können Einladungen erstellen und widerrufen.",
+ "create": "Einladung erstellen",
+ "createTitle": "Einladung erstellen",
+ "createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.",
+ "ttlLegend": "Gültig für",
+ "createDays": "{days} Tage",
+ "maxUsesLabel": "Maximale Nutzungen",
+ "maxUsesHint": "Wie viele Registrierungen dieser Link zulässt (1–50).",
+ "createConfirm": "Einladung erstellen",
+ "createdToast": "Einladung erstellt.",
+ "createError": "Einladung konnte nicht erstellt werden.",
+ "tokenTitle": "Einladungslink",
+ "tokenOnceHint": "Nur jetzt kopierbar — kopiere den Link oder lass den QR-Code scannen.",
+ "hashExplainer": "Gespeichert wird nur ein Hash des Links — er kann später nicht erneut angezeigt werden. Geht er verloren: Einladung widerrufen und neu erstellen.",
+ "copyLink": "Link kopieren",
+ "copiedToast": "Link kopiert.",
+ "copyError": "Kopieren fehlgeschlagen.",
+ "qrAlt": "QR-Code des Einladungslinks",
+ "loadError": "Einladungen konnten nicht geladen werden.",
+ "emptyTitle": "Noch keine Einladungen",
+ "emptyDescription": "Erstelle einen Einladungslink, um jemanden trotz geschlossener Registrierung aufzunehmen.",
+ "colStatus": "Status",
+ "colCreated": "Erstellt",
+ "colExpires": "Läuft ab",
+ "colUses": "Nutzungen",
+ "colRedeemedBy": "Eingelöst von",
+ "colActions": "Aktionen",
+ "byCreator": "von {by}",
+ "expiresInDaysOne": "in 1 Tag",
+ "expiresInDaysOther": "in {count} Tagen",
+ "statusActive": "Aktiv",
+ "statusExpired": "Abgelaufen",
+ "statusExhausted": "Aufgebraucht",
+ "statusRevoked": "Widerrufen",
+ "redeemerDeleted": "gelöschtes Konto",
+ "redeemedByMore": "{username} + {count} weitere",
+ "redemptionsTitle": "{count} Einlösungen",
+ "revoke": "Widerrufen",
+ "revokeAria": "Einladung widerrufen",
+ "revokeConfirmTitle": "Einladung widerrufen?",
+ "revokeConfirmBody": "Der Einladungslink wird sofort ungültig. Bereits registrierte Konten bleiben bestehen, die Einlösungs-Historie bleibt sichtbar.",
+ "revokeConfirmAction": "Widerrufen",
+ "cancel": "Abbrechen",
+ "revokedToast": "Einladung widerrufen.",
+ "revokeError": "Einladung konnte nicht widerrufen werden."
+ },
"userManagement": "Benutzerverwaltung",
"userName": "Name",
"userEmail": "E-Mail",
@@ -4458,6 +4584,10 @@
"emptyDescription": "Es gibt keine Nutzer, die zum aktuellen Filter passen.",
"emptyResetFilter": "Alle Nutzer anzeigen"
},
+ "invites": {
+ "title": "Einladungen",
+ "subtitle": "Einladungslinks erstellen, nachverfolgen und widerrufen."
+ },
"api-tokens": {
"title": "API-Tokens",
"subtitle": "Aktive Bearer-Tokens für native Clients und Integrationen."
@@ -4636,7 +4766,7 @@
},
"compliance801": {
"title": "Treue 80% · 1T",
- "description": "Erreiche an einem Tag eine 30-Tage-Einnahmetreue von mindestens 80%."
+ "description": "Erreiche an einem Tag eine 30-Tage-Therapietreue von mindestens 80%."
},
"bmiGreen1": {
"title": "BMI im Grün · 1T",
@@ -4656,7 +4786,7 @@
},
"compliance807": {
"title": "Treue 80% · 7T",
- "description": "Erreiche 7 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%."
+ "description": "Erreiche 7 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
},
"bmiGreen7": {
"title": "BMI im Grün · 7T",
@@ -4676,7 +4806,7 @@
},
"compliance8030": {
"title": "Treue 80% · 30T",
- "description": "Erreiche 30 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%."
+ "description": "Erreiche 30 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
},
"bmiGreen30": {
"title": "BMI im Grün · 30T",
@@ -4696,7 +4826,7 @@
},
"compliance80180": {
"title": "Treue 80% · 180T",
- "description": "Erreiche 180 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%."
+ "description": "Erreiche 180 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
},
"bmiGreen180": {
"title": "BMI im Grün · 180T",
@@ -4716,7 +4846,7 @@
},
"compliance80360": {
"title": "Treue 80% · 360T",
- "description": "Erreiche 360 Tage in Folge eine 30-Tage-Einnahmetreue von mindestens 80%."
+ "description": "Erreiche 360 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%."
},
"bmiGreen360": {
"title": "BMI im Grün · 360T",
@@ -4903,12 +5033,22 @@
"hourAria": "Stunde der Stimmungs-Erinnerung",
"hourSavedToast": "Erinnerungszeit aktualisiert."
},
+ "coachNudge": {
+ "title": "Proaktiver Coach",
+ "description": "Meldet sich höchstens einmal pro Woche, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel.",
+ "toggleAria": "Proaktive Coach-Hinweise umschalten",
+ "statusOn": "Aktiv — max. 1× pro Woche",
+ "statusOff": "Aus",
+ "enabledToast": "Proaktive Coach-Hinweise aktiviert.",
+ "disabledToast": "Proaktive Coach-Hinweise deaktiviert.",
+ "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
+ },
"eventMedicationReminder": "Medikamenten-Erinnerung",
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
"eventMeasurementAnomaly": "Auffälliger Messwert",
"eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.",
- "eventComplianceLow": "Niedrige Einnahmequote",
- "eventComplianceLowDesc": "Wird gesendet, wenn deine Einnahmequote in den letzten Tagen unter 80 % fällt.",
+ "eventComplianceLow": "Niedrige Therapietreue",
+ "eventComplianceLowDesc": "Wird gesendet, wenn deine Therapietreue in den letzten Tagen unter 80 % fällt.",
"eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen",
"eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.",
"eventSystemAlert": "Systemmeldung",
@@ -5089,6 +5229,20 @@
"dailyTitle": "Stimmung erfassen",
"dailyBody": "Wie geht es dir heute?"
},
+ "coachNudges": {
+ "complianceTitle": "Dein Coach hätte einen Gedanken dazu",
+ "complianceBody": "Deine Einnahmen waren diese Woche lückenhaft. Sprich kurz mit dem Coach — vielleicht lässt sich der Plan vereinfachen.",
+ "bpTitle": "Blutdruck im Wochenmittel erhöht",
+ "bpBody": "Dein Blutdruck lag diese Woche im Schnitt über deinem Ziel. Der Coach kann die Werte mit dir einordnen.",
+ "scoreTitle": "Dein Erholungswert fällt",
+ "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.",
+ "selfContextTitle": "Hilf deinem Coach, dich besser zu kennen",
+ "selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.",
+ "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?",
+ "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?",
+ "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?",
+ "questionAboutMe": "Magst du dem Coach etwas über dich erzählen — Alltag, Ziele, Kontext?"
+ },
"clinicianView": {
"title": "Geteilte Gesundheitsakte",
"period": "Berichtszeitraum: {start} – {end}",
@@ -5096,10 +5250,10 @@
"provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.",
"vitals": "Vitalwerte",
"labs": "Laborwerte",
- "medications": "Medikamente & Einnahmetreue",
+ "medications": "Medikamente & Therapietreue",
"bmi": "Body-Mass-Index (BMI)",
"statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})",
- "adherence": "{rate} Einnahmetreue",
+ "adherence": "{rate} Therapietreue",
"noAdherence": "—",
"glucose": {
"FASTING": "Glukose (nüchtern)",
diff --git a/messages/en.json b/messages/en.json
index aac9080a9..14c8608c5 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -58,11 +58,11 @@
"classification": "Classification",
"bmiTitle": "Body Mass Index (BMI)",
"bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)",
- "complianceTitle": "Medication compliance",
+ "complianceTitle": "Medication adherence",
"glp1Title": "GLP-1 therapy",
"glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)",
"glp1CurrentDose": "Current dose: {value} {unit} (since {since})",
- "glp1Compliance": "Compliance: {taken} / {total} ({rate}%)",
+ "glp1Compliance": "Adherence: {taken} / {total} ({rate}%)",
"glp1SideEffectsTitle": "Side effects logged",
"colGlp1Date": "Date",
"colGlp1Dose": "Dose",
@@ -73,7 +73,7 @@
"colSkipped": "Skipped",
"colMissed": "Missed",
"colTotal": "Total",
- "colComplianceRate": "Compliance rate",
+ "colComplianceRate": "Adherence rate",
"moodTitle": "Mood trajectory",
"moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
"colMood": "Mood",
@@ -144,7 +144,7 @@
"bmi": "Body Mass Index (BMI)",
"mood": "Mood",
"moodSensitive": "Sensitive — off by default.",
- "compliance": "Medication compliance",
+ "compliance": "Medication adherence",
"sleep": "Sleep duration",
"cycle": "Menstrual cycle",
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
@@ -192,6 +192,9 @@
"offlineBanner": {
"message": "No connection — your changes will save once you're back online."
},
+ "demoBanner": {
+ "message": "Demo mode — changes are not saved."
+ },
"nav": {
"dashboard": "Dashboard",
"measurements": "Measurements",
@@ -251,6 +254,7 @@
"hasAccount": "Already have an account?",
"loginFailed": "Login failed",
"registerFailed": "Registration failed",
+ "invitedBanner": "You have been invited — create your account even while registration is otherwise closed.",
"createAccount": "Create account",
"newAccount": "Create new account",
"backToPasskey": "Back to Passkey",
@@ -314,7 +318,7 @@
"confirm": "Discard",
"cancel": "Keep editing"
},
- "compliance7d": "Compliance (7 days)",
+ "compliance7d": "Adherence (7 days)",
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"layoutReset": "Reset to defaults",
@@ -646,7 +650,7 @@
"slotError": "The selected slot couldn’t be matched to a time. Pick another slot or “No specific slot”."
},
"emptyTitle": "No medications yet",
- "emptyDescription": "Add medications, dosages, and reminder windows to track compliance.",
+ "emptyDescription": "Add medications, dosages, and reminder windows to track adherence.",
"loadFailed": "Could not load medications",
"retryLoad": "Retry",
"name": "Name",
@@ -660,7 +664,7 @@
"intakeHistory": "Intake history",
"openDetailPage": "Open medication detail page",
"deleteConfirm": "Delete medication?",
- "compliance": "Compliance",
+ "compliance": "Adherence",
"importIntakes": "Import intakes",
"importDescription": "Paste a JSON array with intake data. Expected format:",
"importUploadFile": "Upload JSON file",
@@ -716,9 +720,11 @@
"takeNow": "Take now",
"overdue": "Overdue",
"veryOverdue": "Very overdue",
- "compliance7d": "7-day compliance",
- "compliance30d": "30-day compliance",
- "complianceWindow": "{days}-day compliance",
+ "compliance7d": "Adherence (7 days)",
+ "compliance30d": "Adherence (30 days)",
+ "complianceWindow": "Adherence ({days} days)",
+ "complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.",
+ "complianceInfoLabel": "What does adherence mean?",
"complianceDoses": "{count} doses",
"lastIntake": "Last intake:",
"nextIntake": "Next intake:",
@@ -1046,6 +1052,8 @@
"subline": "Review everything below before you save.",
"remindersLabel": "Reminders on",
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
+ "noChannelHint": "Reminders need a notification channel — none is active right now.",
+ "noChannelHintLink": "Set up notifications",
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
"short": "Done"
}
@@ -1282,19 +1290,6 @@
"paused": "Paused",
"ended": "Ended"
},
- "today": {
- "groupLabel": "Today's dose",
- "taken": "Taken",
- "skipped": "Skipped",
- "toastTaken": "Today's dose logged",
- "toastSkipped": "Today's dose skipped",
- "recordedTaken": "Taken today at {time}",
- "recordedSkipped": "Skipped today at {time}",
- "recordedOneShot": "One-shot dose taken at {time}",
- "noneScheduled": "No dose scheduled for today.",
- "pausedHint": "Paused — no reminder today.",
- "error": "Could not log the dose."
- },
"cadence": {
"oneShotOn": "One-time dose on {date}.",
"oneShotPending": "One-time dose pending."
@@ -1349,6 +1344,9 @@
"add": "Entry",
"markTaken": "Taken",
"markSkipped": "Skipped",
+ "rowActionsLabel": "Actions for {time}",
+ "markAsTaken": "Mark as taken",
+ "markAsSkipped": "Mark as skipped",
"adHocTag": "off-schedule",
"takenDetail": "planned {planned} · taken {taken}",
"status": {
@@ -1373,6 +1371,18 @@
"body": "This intake falls just outside the {slot} window. Attribute it to that slot, or keep it as an off-schedule entry?",
"keepAdHoc": "Off-schedule",
"pin": "Attribute"
+ },
+ "pin": {
+ "action": "Attribute to slot {time}",
+ "release": "Release attribution",
+ "pinnedTag": "attributed",
+ "pinnedToast": "Intake attributed to slot",
+ "unpinnedToast": "Attribution released",
+ "dueContext": "was due: {time} ({delta})",
+ "confirmTitle": "Attribute to slot {time}?",
+ "confirmBody": "The intake counts as taken late for this slot – the rate is never flattered. You can release the attribution at any time.",
+ "confirmCancel": "Cancel",
+ "confirmAction": "Attribute"
}
},
"notifications": {
@@ -1392,8 +1402,8 @@
"requiresCourseWindow": "Phases are available once a course window is set."
},
"grace": {
- "label": "Reminder window — applies to your primary schedule",
- "primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
+ "label": "Reminder window",
+ "appliesToAllNote": "Applies to every schedule of this medication.",
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
@@ -1468,7 +1478,6 @@
"title": "Advanced settings",
"group": {
"data": "Data",
- "reminders": "Reminders",
"lifecycle": "Lifecycle",
"externalApi": "External API"
},
@@ -1487,8 +1496,7 @@
}
},
"header": {
- "historyLabel": "View intake history",
- "advancedLabel": "Advanced settings"
+ "historyLabel": "View intake history"
},
"history": {
"back": "Back to medication",
@@ -1498,7 +1506,6 @@
"tabs": {
"uebersicht": "Overview",
"zeitplan": "Schedule",
- "erinnerung": "Reminders",
"bestand": "Supply",
"verlauf": "History",
"injektion": "Injection",
@@ -1510,6 +1517,17 @@
"tablistLabel": "Medication detail sections"
},
"uebersicht": {
+ "statusTitle": "Status",
+ "nextDoseLabel": "Next dose",
+ "nextDoseNone": "No upcoming dose.",
+ "pausedHint": "Paused — no reminders.",
+ "reminderLabel": "Reminder",
+ "reminderOn": "On",
+ "reminderOff": "Off",
+ "supplyLabel": "Supply",
+ "supplyRunway": "Lasts about {days} more days",
+ "jumpToZeitplan": "Open schedule",
+ "jumpToBestand": "Open supply",
"complianceTitle": "Adherence",
"complianceEmpty": "No adherence data yet."
},
@@ -1542,7 +1560,7 @@
"scheduleLabel": "Schedule {index}",
"fullEditNote": "Frequency, treatment class and course window are edited through “Full edit”.",
"graceEcho": "Reminder window: {minutes} minutes",
- "graceLink": "Change under “Reminders”",
+ "reminderTitle": "Reminder",
"save": "Save schedule",
"saved": "Schedule saved",
"failed": "Could not save the schedule",
@@ -1573,6 +1591,7 @@
"charts": {
"loadingLabel": "Loading chart…",
"loadingSlowHint": "Computing analytics — this can take a moment.",
+ "adjustTargets": "Adjust targets",
"weight": "Weight",
"bloodPressure": "Blood Pressure",
"pulse": "Pulse",
@@ -1697,7 +1716,7 @@
"lastMonth": "Comparison unavailable — no data from last month yet",
"lastYear": "Comparison unavailable — no data from last year yet"
},
- "notAvailableForCompliance": "Comparison N/A for compliance"
+ "notAvailableForCompliance": "Comparison N/A for adherence"
},
"insights": {
"noAnalysisYet": "No assessment yet.",
@@ -1714,8 +1733,7 @@
"dragHandle": "Drag to reorder",
"show": "Show",
"hide": "Hide",
- "manageSubPages": "Manage detail pages",
- "manageSubPagesHint": "Hidden pages are also removed from the top navigation.",
+ "manageInSettings": "Manage pills & detail pages in Settings",
"groupSleep": "Sleep",
"groupMood": "Mood",
"groupEvents": "Events",
@@ -1806,13 +1824,13 @@
"componentBp": "BP",
"componentWeight": "Weight",
"componentMood": "Mood",
- "componentCompliance": "Meds",
+ "componentCompliance": "Adherence",
"disclaimer": "Indicative — not a clinical assessment.",
"deltaExplainer": {
"trigger": "What does this delta mean?",
"title": "How the delta is computed",
"description": "Change vs last week: {delta}",
- "body": "The score combines BP, weight, mood and meds. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up."
+ "body": "The score combines BP, weight, mood and medication adherence. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up."
},
"provenance": {
"toggle": "Driven by",
@@ -1843,7 +1861,10 @@
"emptyTitle": "No briefing yet",
"emptyDescription": "Generate today's briefing to get a narrative read of your trends.",
"emptyAction": "Generate briefing",
- "loadingLabel": "Synthesising today's briefing"
+ "loadingLabel": "Synthesising today's briefing",
+ "noProviderTitle": "No AI provider connected",
+ "noProviderDescription": "Connect an AI provider in settings to receive daily briefings.",
+ "noProviderAction": "Open AI settings"
},
"trendsRow": {
"title": "Trends",
@@ -1911,8 +1932,8 @@
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence",
"card": {
- "bpComplianceTitle": "BP × medication compliance",
- "bpComplianceSubtitle": "Daily systolic against compliance %",
+ "bpComplianceTitle": "BP × medication adherence",
+ "bpComplianceSubtitle": "Daily systolic against adherence in %",
"moodPulseTitle": "Mood × resting pulse",
"moodPulseSubtitle": "Same-day mood and resting pulse",
"weightWeekdayTitle": "Weight × weekday",
@@ -1936,6 +1957,8 @@
"composerPlaceholder": "Ask anything about your data…",
"composerHint": "Press Enter to send, Shift+Enter for a new line.",
"composerDisclaimer": "Coach replies are generated. Clinical decisions belong with your doctor.",
+ "selfContextChipsLabel": "The Coach would like to know:",
+ "selfContextChipDismiss": "Dismiss question",
"voiceComingSoon": "Voice input arrives with the iOS app in v1.5.",
"threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.",
"historyTitle": "Conversations",
@@ -1953,7 +1976,7 @@
"weight": "Weight",
"pulse": "Pulse",
"mood": "Mood",
- "compliance": "Compliance",
+ "compliance": "Adherence",
"general": "General",
"hrv": "HRV",
"sleep": "Sleep",
@@ -2099,6 +2122,8 @@
"navSteps": "Steps",
"navActiveEnergy": "Active energy",
"navAriaLabel": "Skip to section",
+ "pillScrollLeft": "Scroll tabs left",
+ "pillScrollRight": "Scroll tabs right",
"tabStrip": {
"vitalsParent": {
"label": "Vitals",
@@ -2301,11 +2326,11 @@
"stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.",
"breathingDisturbancesTitle": "Sleep breathing disturbances",
"breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.",
- "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means your system looks recovered and resourced; a lower one is a gentle cue to ease off and let your body catch up. Treat it as guidance for pacing the day, not a verdict.",
- "sleepScoreBody": "Your sleep score distils a whole night into one number, weighing how long you slept, how efficiently, how regular your timing was, and the balance of light, deep, and REM. It is meant to show at a glance whether the night truly restored you. Watch the pattern across weeks rather than chasing a perfect score on any single night.",
- "recoveryBody": "Recovery reflects how well your body has bounced back and how much capacity you have to take on load today, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding. It is a guide for how hard to lean in, not a limit.",
- "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, so it reflects what is demanding for you specifically. Use it to balance hard days with easier ones and keep your training sustainable.",
- "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calmer, well-regulated state; a higher one is an invitation to build in recovery. It is a descriptive proxy to notice trends, not a clinical measurement."
+ "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.",
+ "sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.",
+ "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding.",
+ "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, not someone else's baseline.",
+ "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery."
},
"stats": {
"label": "Summary statistics",
@@ -2359,10 +2384,10 @@
}
},
"correlations": "Correlations",
- "medicationCompliance": "Medication Compliance",
+ "medicationCompliance": "Medication adherence",
"dayStreak": "{count} day streak",
- "compliance7d": "7-day compliance",
- "compliance30d": "30-day compliance",
+ "compliance7d": "Adherence (7 days)",
+ "compliance30d": "Adherence (30 days)",
"taken": "taken",
"skipped": "skipped",
"missed": "missed",
@@ -2378,7 +2403,7 @@
"weightSectionTitle": "Weight",
"pulseSectionTitle": "Pulse",
"bmiSectionTitle": "BMI",
- "complianceNoData": "No compliance data available.",
+ "complianceNoData": "No adherence data available.",
"generate": "Generate new analysis",
"moodSectionTitle": "Mood",
"confidence": "Confidence",
@@ -2411,7 +2436,7 @@
},
"medication": {
"title": "No medications set up yet",
- "description": "Set up a medication and intake compliance, drug level and titration timeline will appear here.",
+ "description": "Set up a medication and adherence, drug level and titration timeline will appear here.",
"cta": "Add medication"
},
"sleep": {
@@ -3148,7 +3173,7 @@
},
"pillOrder": {
"title": "Sort the navigation pills",
- "description": "Drag the pills into the order they should appear in along the top of Insights.",
+ "description": "Drag the pills into the order you want and show or hide them via the eye icon. Hidden detail pages are also removed from the top navigation.",
"dragHandle": "Reorder",
"saveSuccess": "Pill order saved.",
"saveError": "Saving the pill order failed.",
@@ -3156,6 +3181,8 @@
"empty": "No navigation pills available yet."
},
"customize": "Customize Insights",
+ "regenerateRateLimited": "Refresh limit reached — please try again later.",
+ "regenerateUnavailable": "No fresh analysis arrived — please try again later.",
"regenerateError": "Still refreshing — please try again in a moment."
},
"thresholds": {
@@ -3327,7 +3354,7 @@
"measurementDescription": "One reading unlocks the tile — the trend itself draws from your 5th reading.",
"measurementCta": "Add measurement",
"medicationTitle": "Add a medication",
- "medicationDescription": "Track compliance and get reminders right when you need them.",
+ "medicationDescription": "Track adherence and get reminders right when you need them.",
"medicationCta": "Add medication",
"withingsTitle": "Connect Withings (optional)",
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
@@ -3382,6 +3409,8 @@
"sources": {
"title": "Source priority",
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
+ "integrationsHint": "Connect or disconnect data sources on the Integrations page.",
+ "integrationsHintLink": "Open integrations",
"cardTitle": "Source priority by metric",
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
"resetDefaults": "Reset to defaults",
@@ -3455,7 +3484,7 @@
"downloadFailed": "Couldn't download the export. Please try again.",
"hero": {
"eyebrow": "Doctor visit",
- "valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication compliance and (optionally) mood, all on a few pages.",
+ "valueStatement": "A printable PDF report for your doctor's appointment — vitals, BMI, blood-pressure classification, medication adherence and (optionally) mood, all on a few pages.",
"cta": "Configure & generate report",
"formatHint": "PDF · print-ready"
},
@@ -3469,7 +3498,7 @@
"cards": {
"doctorReport": {
"title": "Doctor Report",
- "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood."
+ "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication adherence, mood."
},
"measurementsCsv": {
"title": "Measurements",
@@ -3553,6 +3582,15 @@
"profile": "Profile",
"language": "Language",
"languageDescription": "Choose the display language of the application.",
+ "timeFormat": {
+ "title": "Hour format",
+ "description": "Controls whether times render on the 24-hour or the 12-hour (AM/PM) clock.",
+ "auto": "Automatic (language)",
+ "h24": "24-hour",
+ "h12": "12-hour (AM/PM)",
+ "saved": "Hour format saved.",
+ "saveError": "Saving the hour format failed."
+ },
"username": "Username",
"height": "Height (cm)",
"dateOfBirth": "Date of birth",
@@ -3642,8 +3680,8 @@
"codexDisconnected": "ChatGPT connection removed.",
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
"rawData": "Send raw data",
- "rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so the provider can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.",
- "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but the provider can be less precise for short-term patterns and fluctuations.",
+ "rawDataOnDescription": "Aggregated metrics plus anonymized individual points from the last 30 days with time context are sent — never your name, email, or direct account identifiers.",
+ "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum) — no individual points and no exact time context.",
"rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.",
"regenerateInsights": "Regenerate reports",
"regenerateSuccess": "Reports regenerated successfully",
@@ -3775,6 +3813,30 @@
"savedHidden": "Coach hidden.",
"savedShown": "Coach visible.",
"saveError": "Could not save the change. Please try again."
+ },
+ "aboutMe": {
+ "title": "About me",
+ "description": "Personal context the Coach and the daily briefing read alongside your data.",
+ "placeholder": "E.g. shift work, vegetarian diet, knee injury, training for a half marathon…",
+ "charCount": "{used} / {max} characters",
+ "clear": "Clear",
+ "save": "Save",
+ "savedToast": "About-me note saved.",
+ "clearedToast": "About-me note cleared.",
+ "saveError": "Could not save. Please try again.",
+ "loadError": "The note could not be loaded.",
+ "hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing.",
+ "profileHint": "Age and gender come from your profile and are included automatically.",
+ "freeTextLabel": "About me (free text)",
+ "conditionsLabel": "Chronic conditions",
+ "conditionsPlaceholder": "E.g. hypertension, type 2 diabetes…",
+ "allergiesLabel": "Allergies & intolerances",
+ "allergiesPlaceholder": "E.g. lactose intolerance, penicillin allergy…",
+ "focusLabel": "What should the Coach watch?",
+ "focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…",
+ "completeness": "{answered} of {total} answered",
+ "questionsTitle": "The Coach has follow-up questions",
+ "questionsOpenCoach": "Answer them in the Coach"
}
},
"dashboard": {
@@ -3804,6 +3866,21 @@
"withingsSyncResult": "{count} measurements synchronized",
"withingsFullSyncResult": "{count} measurements fully synchronized",
"withingsSyncFailed": "Sync failed",
+ "withingsOauthConnected": "Withings connected — the first sync starts in the background.",
+ "withingsOauthFailed": "Withings connection failed",
+ "withingsOauthError": {
+ "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
+ "replay": "This confirmation link has already been used. Please start the connection again.",
+ "state": "The response from Withings did not match any open connection request. Please start the connection again.",
+ "expired": "The connection request expired. Start the connection again and complete the approval within a few minutes.",
+ "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
+ "nocode": "Withings did not return an authorization code. Try again and allow access in the Withings dialog.",
+ "nocreds": "No Withings API credentials are stored. Save the client ID and client secret first, then connect again.",
+ "token": "The token exchange with Withings failed. Check the client ID and client secret and try again.",
+ "generic": "Unknown error while connecting. Please start the connection again."
+ },
+ "integrationsSourcesHint": "When several sources report the same metric, the source priority decides which value counts.",
+ "integrationsSourcesHintLink": "Open source priority",
"withingsSynchronize": "Synchronize",
"withingsDisconnect": "Disconnect",
"withingsDisconnectTitle": "Disconnect Withings?",
@@ -4240,6 +4317,55 @@
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Issue token",
"bugReportTokenPlaceholder": "ghp_...",
+ "invites": {
+ "title": "Invites",
+ "description": "Registration invite links — they work even while registration is closed. The link is shown only once.",
+ "adminOnlyHint": "Only admins can create and revoke invites.",
+ "create": "Create invite",
+ "createTitle": "Create invite",
+ "createDescription": "The link admits a signup even while registration is closed.",
+ "ttlLegend": "Valid for",
+ "createDays": "{days} days",
+ "maxUsesLabel": "Maximum uses",
+ "maxUsesHint": "How many signups this link admits (1–50).",
+ "createConfirm": "Create invite",
+ "createdToast": "Invite created.",
+ "createError": "Could not create the invite.",
+ "tokenTitle": "Invite link",
+ "tokenOnceHint": "Copyable only now — copy the link or have the QR code scanned.",
+ "hashExplainer": "Only a hash of the link is stored, so it cannot be shown again later. If it gets lost, revoke the invite and create a new one.",
+ "copyLink": "Copy link",
+ "copiedToast": "Link copied.",
+ "copyError": "Copy failed.",
+ "qrAlt": "QR code for the invite link",
+ "loadError": "Could not load the invites.",
+ "emptyTitle": "No invites yet",
+ "emptyDescription": "Create an invite link to admit someone while registration is closed.",
+ "colStatus": "Status",
+ "colCreated": "Created",
+ "colExpires": "Expires",
+ "colUses": "Uses",
+ "colRedeemedBy": "Redeemed by",
+ "colActions": "Actions",
+ "byCreator": "by {by}",
+ "expiresInDaysOne": "in 1 day",
+ "expiresInDaysOther": "in {count} days",
+ "statusActive": "Active",
+ "statusExpired": "Expired",
+ "statusExhausted": "Used up",
+ "statusRevoked": "Revoked",
+ "redeemerDeleted": "deleted account",
+ "redeemedByMore": "{username} + {count} more",
+ "redemptionsTitle": "{count} redemptions",
+ "revoke": "Revoke",
+ "revokeAria": "Revoke invite",
+ "revokeConfirmTitle": "Revoke this invite?",
+ "revokeConfirmBody": "The invite link becomes invalid immediately. Accounts already registered through it remain, and the redemption history stays visible.",
+ "revokeConfirmAction": "Revoke",
+ "cancel": "Cancel",
+ "revokedToast": "Invite revoked.",
+ "revokeError": "Could not revoke the invite."
+ },
"userManagement": "User Management",
"userName": "Name",
"userEmail": "Email",
@@ -4458,6 +4584,10 @@
"emptyDescription": "There are no users matching the current filter.",
"emptyResetFilter": "Show all users"
},
+ "invites": {
+ "title": "Invites",
+ "subtitle": "Mint, track, and revoke registration invite links."
+ },
"api-tokens": {
"title": "API Tokens",
"subtitle": "Active bearer tokens issued to native clients and integrations."
@@ -4903,12 +5033,22 @@
"hourAria": "Mood reminder hour",
"hourSavedToast": "Reminder time updated."
},
+ "coachNudge": {
+ "title": "Proactive Coach",
+ "description": "Reaches out at most once a week when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average.",
+ "toggleAria": "Toggle proactive Coach nudges",
+ "statusOn": "Active — max. once a week",
+ "statusOff": "Off",
+ "enabledToast": "Proactive Coach nudges enabled.",
+ "disabledToast": "Proactive Coach nudges disabled.",
+ "saveError": "Could not save. Please try again later."
+ },
"eventMedicationReminder": "Medication reminder",
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
"eventMeasurementAnomaly": "Unusual measurement",
"eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.",
- "eventComplianceLow": "Low compliance",
- "eventComplianceLowDesc": "Sent when your intake rate over the last few days drops below 80 %.",
+ "eventComplianceLow": "Low adherence",
+ "eventComplianceLowDesc": "Sent when your adherence over the last few days drops below 80 %.",
"eventWithingsSyncFailed": "Withings sync failed",
"eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.",
"eventSystemAlert": "System notice",
@@ -5089,6 +5229,20 @@
"dailyTitle": "Log your mood",
"dailyBody": "How are you feeling today?"
},
+ "coachNudges": {
+ "complianceTitle": "Your Coach has a thought on this",
+ "complianceBody": "Your intakes were patchy this week. Have a quick word with the Coach — maybe the plan can be simplified.",
+ "bpTitle": "Blood pressure elevated this week",
+ "bpBody": "Your blood pressure averaged above your target this week. The Coach can help you put the readings in context.",
+ "scoreTitle": "Your recovery score is dropping",
+ "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.",
+ "selfContextTitle": "Help your Coach know you better",
+ "selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.",
+ "questionConditions": "Do you have any chronic conditions the Coach should know about?",
+ "questionFocus": "Is there anything specific the Coach should pay attention to?",
+ "questionAllergies": "Do you have any allergies or intolerances?",
+ "questionAboutMe": "Want to tell the Coach a bit about yourself — routines, goals, context?"
+ },
"clinicianView": {
"title": "Shared health record",
"period": "Reporting period: {start} – {end}",
diff --git a/messages/es.json b/messages/es.json
index b37cd76c0..d3caeac73 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -192,6 +192,9 @@
"offlineBanner": {
"message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea."
},
+ "demoBanner": {
+ "message": "Modo demo — los cambios no se guardan."
+ },
"nav": {
"dashboard": "Panel",
"measurements": "Mediciones",
@@ -251,6 +254,7 @@
"hasAccount": "¿Ya tienes cuenta?",
"loginFailed": "No se ha podido iniciar sesión",
"registerFailed": "No se ha podido completar el registro",
+ "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.",
"createAccount": "Crear cuenta",
"newAccount": "Crear cuenta nueva",
"backToPasskey": "Volver a passkey",
@@ -719,6 +723,8 @@
"compliance7d": "Cumplimiento 7 días",
"compliance30d": "Cumplimiento 30 días",
"complianceWindow": "Cumplimiento {days} días",
+ "complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.",
+ "complianceInfoLabel": "¿Qué significa el cumplimiento?",
"complianceDoses": "{count} dosis",
"lastIntake": "Última toma:",
"nextIntake": "Próxima toma:",
@@ -1046,6 +1052,8 @@
"subline": "Revisa todo abajo antes de guardar.",
"remindersLabel": "Recordatorios activados",
"remindersDescription": "Push, Telegram o ntfy — según tus ajustes.",
+ "noChannelHint": "Los recordatorios necesitan un canal de notificaciones — ahora mismo no hay ninguno activo.",
+ "noChannelHintLink": "Configurar notificaciones",
"multiScheduleNote": "Este medicamento tiene varios horarios. El editor detallado volverá en una versión posterior.",
"short": "Listo"
}
@@ -1282,19 +1290,6 @@
"paused": "Pausado",
"ended": "Finalizado"
},
- "today": {
- "groupLabel": "Dosis de hoy",
- "taken": "Tomada",
- "skipped": "Omitida",
- "toastTaken": "Dosis de hoy registrada",
- "toastSkipped": "Dosis de hoy omitida",
- "recordedTaken": "Tomada hoy a las {time}",
- "recordedSkipped": "Omitida hoy a las {time}",
- "recordedOneShot": "Dosis única tomada a las {time}",
- "noneScheduled": "No hay ninguna dosis programada para hoy.",
- "pausedHint": "En pausa — hoy no hay recordatorio.",
- "error": "No se pudo registrar la dosis."
- },
"cadence": {
"oneShotOn": "Dosis única el {date}.",
"oneShotPending": "Dosis única pendiente."
@@ -1349,6 +1344,9 @@
"add": "Entrada",
"markTaken": "Tomada",
"markSkipped": "Saltada",
+ "rowActionsLabel": "Acciones para {time}",
+ "markAsTaken": "Marcar como tomada",
+ "markAsSkipped": "Marcar como omitida",
"adHocTag": "fuera de horario",
"takenDetail": "prevista {planned} · tomada {taken}",
"status": {
@@ -1373,6 +1371,18 @@
"body": "Esta toma queda justo fuera de la ventana de {slot}. ¿Asignarla a ese horario o guardarla como entrada fuera de horario?",
"keepAdHoc": "Fuera de horario",
"pin": "Asignar"
+ },
+ "pin": {
+ "action": "Asignar al horario de las {time}",
+ "release": "Quitar asignación",
+ "pinnedTag": "asignada",
+ "pinnedToast": "Toma asignada al horario",
+ "unpinnedToast": "Asignación quitada",
+ "dueContext": "prevista: {time} ({delta})",
+ "confirmTitle": "¿Asignar al horario de las {time}?",
+ "confirmBody": "La toma contará como tomada con retraso para este horario; el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.",
+ "confirmCancel": "Cancelar",
+ "confirmAction": "Asignar"
}
},
"notifications": {
@@ -1392,8 +1402,8 @@
"requiresCourseWindow": "Las fases están disponibles una vez fijado un periodo de tratamiento."
},
"grace": {
- "label": "Ventana de recordatorio — se aplica a tu horario principal",
- "primaryScheduleNote": "Los medicamentos con varios horarios mantienen la ventana predeterminada de sus demás horarios.",
+ "label": "Ventana de recordatorio",
+ "appliesToAllNote": "Se aplica a todos los horarios de este medicamento.",
"unit": "minutos",
"saved": "Ventana de recordatorio guardada",
"failed": "No se pudo guardar la ventana de recordatorio."
@@ -1468,7 +1478,6 @@
"title": "Ajustes avanzados",
"group": {
"data": "Datos",
- "reminders": "Recordatorios",
"lifecycle": "Ciclo de vida",
"externalApi": "API externa"
},
@@ -1487,8 +1496,7 @@
}
},
"header": {
- "historyLabel": "Ver historial de tomas",
- "advancedLabel": "Configuración avanzada"
+ "historyLabel": "Ver historial de tomas"
},
"history": {
"back": "Volver al medicamento",
@@ -1498,7 +1506,6 @@
"tabs": {
"uebersicht": "Resumen",
"zeitplan": "Horario",
- "erinnerung": "Recordatorios",
"bestand": "Existencias",
"verlauf": "Historial",
"injektion": "Inyección",
@@ -1510,6 +1517,17 @@
"tablistLabel": "Secciones de detalle del medicamento"
},
"uebersicht": {
+ "statusTitle": "Estado",
+ "nextDoseLabel": "Próxima dosis",
+ "nextDoseNone": "No hay dosis pendiente.",
+ "pausedHint": "En pausa — sin recordatorios.",
+ "reminderLabel": "Recordatorio",
+ "reminderOn": "Activado",
+ "reminderOff": "Desactivado",
+ "supplyLabel": "Existencias",
+ "supplyRunway": "Alcanza para unos {days} días más",
+ "jumpToZeitplan": "Ver horario",
+ "jumpToBestand": "Ver existencias",
"complianceTitle": "Adherencia",
"complianceEmpty": "Aún no hay datos de adherencia."
},
@@ -1542,7 +1560,7 @@
"scheduleLabel": "Horario {index}",
"fullEditNote": "La frecuencia, la clase de tratamiento y la ventana del curso se editan en «Edición completa».",
"graceEcho": "Ventana de recordatorio: {minutes} minutos",
- "graceLink": "Cambiar en «Recordatorios»",
+ "reminderTitle": "Recordatorio",
"save": "Guardar horario",
"saved": "Horario guardado",
"failed": "No se pudo guardar el horario",
@@ -1573,6 +1591,7 @@
"charts": {
"loadingLabel": "Cargando la gráfica…",
"loadingSlowHint": "Calculando los análisis — puede tardar un momento.",
+ "adjustTargets": "Ajustar objetivos",
"weight": "Peso",
"bloodPressure": "Tensión arterial",
"pulse": "Pulso",
@@ -1714,8 +1733,7 @@
"dragHandle": "Arrastra para reordenar",
"show": "Mostrar",
"hide": "Ocultar",
- "manageSubPages": "Gestionar páginas de detalle",
- "manageSubPagesHint": "Las páginas ocultas también se quitan de la navegación superior.",
+ "manageInSettings": "Gestionar pestañas y páginas de detalle en los ajustes",
"groupSleep": "Sueño",
"groupMood": "Estado de ánimo",
"groupEvents": "Eventos",
@@ -1843,7 +1861,10 @@
"emptyTitle": "Aún no hay resumen",
"emptyDescription": "Genera el resumen de hoy para tener una vista rápida de tus tendencias.",
"emptyAction": "Generar resumen",
- "loadingLabel": "Preparando el resumen"
+ "loadingLabel": "Preparando el resumen",
+ "noProviderTitle": "Ningún proveedor de IA conectado",
+ "noProviderDescription": "Conecta un proveedor de IA en los ajustes para recibir resúmenes diarios.",
+ "noProviderAction": "Abrir ajustes de IA"
},
"trendsRow": {
"title": "Tendencias",
@@ -1936,6 +1957,8 @@
"composerPlaceholder": "Pregúntame sobre tus datos…",
"composerHint": "Intro para enviar, Mayús+Intro para una nueva línea.",
"composerDisclaimer": "Las respuestas son generadas. Las decisiones clínicas las toma tu médico.",
+ "selfContextChipsLabel": "Al Coach le gustaría saber:",
+ "selfContextChipDismiss": "Descartar pregunta",
"voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.",
"threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.",
"historyTitle": "Conversaciones",
@@ -2099,6 +2122,8 @@
"navSteps": "Pasos",
"navActiveEnergy": "Energía activa",
"navAriaLabel": "Saltar a sección",
+ "pillScrollLeft": "Desplazar las pestañas a la izquierda",
+ "pillScrollRight": "Desplazar las pestañas a la derecha",
"tabStrip": {
"vitalsParent": {
"label": "Vitales",
@@ -2301,11 +2326,11 @@
"stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.",
"breathingDisturbancesTitle": "Alteraciones respiratorias del sueño",
"breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional.",
- "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo y dejar que tu cuerpo se ponga al día. Tómala como una guía para dosificar el día, no como un veredicto.",
- "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número, sopesando cuánto dormiste, con qué eficiencia, qué tan regular fue tu horario y el equilibrio entre sueño ligero, profundo y REM. Su objetivo es mostrar de un vistazo si la noche realmente te repuso. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.",
- "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto y cuánta capacidad tienes hoy para asumir carga, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo. Es una guía sobre cuánto esforzarte, no un límite.",
- "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, de modo que refleja lo que resulta exigente para ti en concreto. Úsalo para equilibrar los días duros con otros más suaves y mantener un entrenamiento sostenible.",
- "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado más calmado y bien regulado; una más alta es una invitación a incorporar recuperación. Es una aproximación descriptiva para detectar tendencias, no una medición clínica."
+ "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo.",
+ "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.",
+ "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.",
+ "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.",
+ "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación."
},
"stats": {
"label": "Estadísticas resumidas",
@@ -3148,7 +3173,7 @@
},
"pillOrder": {
"title": "Ordenar las pestañas de navegación",
- "description": "Arrastra las pestañas al orden en que deben aparecer en la parte superior de Análisis.",
+ "description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo. Las páginas de detalle ocultas también se quitan de la navegación superior.",
"dragHandle": "Reordenar",
"saveSuccess": "Orden de las pestañas guardado.",
"saveError": "No se pudo guardar el orden de las pestañas.",
@@ -3156,6 +3181,8 @@
"empty": "Aún no hay pestañas de navegación disponibles."
},
"customize": "Personalizar Análisis",
+ "regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.",
+ "regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.",
"regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento."
},
"thresholds": {
@@ -3382,6 +3409,8 @@
"sources": {
"title": "Prioridad de fuentes",
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
+ "integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.",
+ "integrationsHintLink": "Abrir integraciones",
"cardTitle": "Prioridad de fuentes por métrica",
"help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.",
"resetDefaults": "Restablecer valores predeterminados",
@@ -3553,6 +3582,15 @@
"profile": "Perfil",
"language": "Idioma",
"languageDescription": "Elige el idioma de visualización de la aplicación.",
+ "timeFormat": {
+ "title": "Formato horario",
+ "description": "Determina si las horas se muestran en formato de 24 horas o de 12 horas (AM/PM).",
+ "auto": "Automático (idioma)",
+ "h24": "24 horas",
+ "h12": "12 horas (AM/PM)",
+ "saved": "Formato horario guardado.",
+ "saveError": "No se pudo guardar el formato horario."
+ },
"username": "Nombre de usuario",
"height": "Altura (cm)",
"dateOfBirth": "Fecha de nacimiento",
@@ -3642,8 +3680,8 @@
"codexDisconnected": "Conexión con ChatGPT eliminada.",
"codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.",
"rawData": "Enviar datos en bruto",
- "rawDataOnDescription": "Se envían métricas agregadas más puntos en bruto anonimizados de los últimos 30 días. Esto incluye mediciones por métrica (por ejemplo, peso, presión arterial, pulso) con contexto temporal para que el proveedor pueda detectar patrones, valores atípicos y correlaciones de forma más fiable. No se transmiten el nombre, el correo electrónico ni los identificadores directos de la cuenta.",
- "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo). Sin puntos individuales ni contexto temporal exacto. Esto preserva mejor la privacidad, pero el proveedor puede ser menos preciso con los patrones y las fluctuaciones a corto plazo.",
+ "rawDataOnDescription": "Se envían métricas agregadas más puntos individuales anonimizados de los últimos 30 días con contexto temporal — nunca el nombre, el correo electrónico ni los identificadores directos de la cuenta.",
+ "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo) — sin puntos individuales ni contexto temporal exacto.",
"rawDataWarning": "Modo en bruto activado: el proveedor recibe puntos anonimizados adicionales de los últimos 30 días para un análisis más preciso.",
"regenerateInsights": "Regenerar informes",
"regenerateSuccess": "Informes regenerados correctamente",
@@ -3775,6 +3813,30 @@
"savedHidden": "Coach oculto.",
"savedShown": "Coach visible.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
+ },
+ "aboutMe": {
+ "title": "Sobre mí",
+ "description": "Contexto personal que el Coach y el informe diario leen junto con tus datos.",
+ "placeholder": "P. ej. trabajo por turnos, dieta vegetariana, lesión de rodilla, entrenamiento para una media maratón…",
+ "charCount": "{used} / {max} caracteres",
+ "clear": "Vaciar",
+ "save": "Guardar",
+ "savedToast": "Nota «Sobre mí» guardada.",
+ "clearedToast": "Nota «Sobre mí» eliminada.",
+ "saveError": "No se pudo guardar. Inténtalo de nuevo.",
+ "loadError": "No se pudo cargar la nota.",
+ "hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada.",
+ "profileHint": "La edad y el género provienen de tu perfil y se incluyen automáticamente.",
+ "freeTextLabel": "Sobre mí (texto libre)",
+ "conditionsLabel": "Enfermedades crónicas",
+ "conditionsPlaceholder": "P. ej. hipertensión, diabetes tipo 2…",
+ "allergiesLabel": "Alergias e intolerancias",
+ "allergiesPlaceholder": "P. ej. intolerancia a la lactosa, alergia a la penicilina…",
+ "focusLabel": "¿En qué debería fijarse el Coach?",
+ "focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…",
+ "completeness": "{answered} de {total} respondidas",
+ "questionsTitle": "El Coach tiene preguntas",
+ "questionsOpenCoach": "Responderlas en el Coach"
}
},
"dashboard": {
@@ -3804,6 +3866,21 @@
"withingsSyncResult": "{count} mediciones sincronizadas",
"withingsFullSyncResult": "{count} mediciones sincronizadas por completo",
"withingsSyncFailed": "Falló la sincronización",
+ "withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.",
+ "withingsOauthFailed": "Error al conectar con Withings",
+ "withingsOauthError": {
+ "csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
+ "replay": "Este enlace de confirmación ya se ha utilizado. Inicia la conexión de nuevo.",
+ "state": "La respuesta de Withings no coincide con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
+ "expired": "La solicitud de conexión ha caducado. Inicia la conexión de nuevo y completa la autorización en pocos minutos.",
+ "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
+ "nocode": "Withings no devolvió ningún código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Withings.",
+ "nocreds": "No hay credenciales de la API de Withings guardadas. Guarda primero el Client ID y el Client Secret y vuelve a conectar.",
+ "token": "El intercambio de tokens con Withings falló. Comprueba el Client ID y el Client Secret e inténtalo de nuevo.",
+ "generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
+ },
+ "integrationsSourcesHint": "Si varias fuentes aportan la misma métrica, la prioridad de fuentes decide qué valor cuenta.",
+ "integrationsSourcesHintLink": "Abrir prioridad de fuentes",
"withingsSynchronize": "Sincronizar",
"withingsDisconnect": "Desconectar",
"withingsDisconnectTitle": "¿Desconectar Withings?",
@@ -4240,6 +4317,55 @@
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Token de issues",
"bugReportTokenPlaceholder": "ghp_...",
+ "invites": {
+ "title": "Invitaciones",
+ "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.",
+ "adminOnlyHint": "Solo los administradores pueden crear y revocar invitaciones.",
+ "create": "Crear invitación",
+ "createTitle": "Crear invitación",
+ "createDescription": "El enlace permite registrarse incluso con el registro cerrado.",
+ "ttlLegend": "Válida durante",
+ "createDays": "{days} días",
+ "maxUsesLabel": "Usos máximos",
+ "maxUsesHint": "Cuántos registros admite este enlace (1–50).",
+ "createConfirm": "Crear invitación",
+ "createdToast": "Invitación creada.",
+ "createError": "No se pudo crear la invitación.",
+ "tokenTitle": "Enlace de invitación",
+ "tokenOnceHint": "Solo se puede copiar ahora: copia el enlace o deja que escaneen el código QR.",
+ "hashExplainer": "Solo se guarda un hash del enlace, por lo que no se puede volver a mostrar. Si se pierde, revoca la invitación y crea una nueva.",
+ "copyLink": "Copiar enlace",
+ "copiedToast": "Enlace copiado.",
+ "copyError": "No se pudo copiar.",
+ "qrAlt": "Código QR del enlace de invitación",
+ "loadError": "No se pudieron cargar las invitaciones.",
+ "emptyTitle": "Todavía no hay invitaciones",
+ "emptyDescription": "Crea un enlace de invitación para admitir a alguien con el registro cerrado.",
+ "colStatus": "Estado",
+ "colCreated": "Creada",
+ "colExpires": "Caduca",
+ "colUses": "Usos",
+ "colRedeemedBy": "Canjeada por",
+ "colActions": "Acciones",
+ "byCreator": "por {by}",
+ "expiresInDaysOne": "en 1 día",
+ "expiresInDaysOther": "en {count} días",
+ "statusActive": "Activa",
+ "statusExpired": "Caducada",
+ "statusExhausted": "Agotada",
+ "statusRevoked": "Revocada",
+ "redeemerDeleted": "cuenta eliminada",
+ "redeemedByMore": "{username} + {count} más",
+ "redemptionsTitle": "{count} canjes",
+ "revoke": "Revocar",
+ "revokeAria": "Revocar invitación",
+ "revokeConfirmTitle": "¿Revocar esta invitación?",
+ "revokeConfirmBody": "El enlace de invitación dejará de ser válido de inmediato. Las cuentas ya registradas con él se mantienen y el historial de canjes sigue visible.",
+ "revokeConfirmAction": "Revocar",
+ "cancel": "Cancelar",
+ "revokedToast": "Invitación revocada.",
+ "revokeError": "No se pudo revocar la invitación."
+ },
"userManagement": "Gestión de usuarios",
"userName": "Nombre",
"userEmail": "Correo electrónico",
@@ -4458,6 +4584,10 @@
"emptyDescription": "No hay usuarios que coincidan con el filtro actual.",
"emptyResetFilter": "Mostrar todos los usuarios"
},
+ "invites": {
+ "title": "Invitaciones",
+ "subtitle": "Crear, seguir y revocar enlaces de invitación."
+ },
"api-tokens": {
"title": "Tokens de API",
"subtitle": "Tokens bearer activos emitidos a clientes nativos e integraciones."
@@ -4903,6 +5033,16 @@
"hourAria": "Hora del recordatorio de ánimo",
"hourSavedToast": "Hora del recordatorio actualizada."
},
+ "coachNudge": {
+ "title": "Coach proactivo",
+ "description": "Se pone en contacto como máximo una vez por semana cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada.",
+ "toggleAria": "Activar o desactivar los avisos proactivos del Coach",
+ "statusOn": "Activo — máx. 1 vez por semana",
+ "statusOff": "Desactivado",
+ "enabledToast": "Avisos proactivos del Coach activados.",
+ "disabledToast": "Avisos proactivos del Coach desactivados.",
+ "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde."
+ },
"eventMedicationReminder": "Recordatorio de medicación",
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
"eventMeasurementAnomaly": "Medición inusual",
@@ -5089,6 +5229,20 @@
"dailyTitle": "Registrar el ánimo",
"dailyBody": "¿Cómo te sientes hoy?"
},
+ "coachNudges": {
+ "complianceTitle": "Tu Coach tiene algo que comentarte",
+ "complianceBody": "Tus tomas han sido irregulares esta semana. Habla un momento con el Coach: quizá se pueda simplificar el plan.",
+ "bpTitle": "Tensión elevada esta semana",
+ "bpBody": "Tu tensión media de esta semana superó tu objetivo. El Coach puede ayudarte a poner los valores en contexto.",
+ "scoreTitle": "Tu puntuación de recuperación baja",
+ "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.",
+ "selfContextTitle": "Ayuda a tu Coach a conocerte mejor",
+ "selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.",
+ "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?",
+ "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?",
+ "questionAllergies": "¿Tienes alergias o intolerancias?",
+ "questionAboutMe": "¿Quieres contarle al Coach algo sobre ti: rutinas, objetivos, contexto?"
+ },
"clinicianView": {
"title": "Registro de salud compartido",
"period": "Período del informe: {start} – {end}",
diff --git a/messages/fr.json b/messages/fr.json
index 3399e0725..6a0fd1bce 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -192,6 +192,9 @@
"offlineBanner": {
"message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne."
},
+ "demoBanner": {
+ "message": "Mode démo — les modifications ne sont pas enregistrées."
+ },
"nav": {
"dashboard": "Tableau de bord",
"measurements": "Mesures",
@@ -251,6 +254,7 @@
"hasAccount": "Déjà un compte ?",
"loginFailed": "Échec de la connexion",
"registerFailed": "Échec de l’inscription",
+ "invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.",
"createAccount": "Créer un compte",
"newAccount": "Créer un nouveau compte",
"backToPasskey": "Revenir à la passkey",
@@ -719,6 +723,8 @@
"compliance7d": "Observance 7 jours",
"compliance30d": "Observance 30 jours",
"complianceWindow": "Observance {days} jours",
+ "complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.",
+ "complianceInfoLabel": "Que signifie l'observance ?",
"complianceDoses": "{count} doses",
"lastIntake": "Dernière prise :",
"nextIntake": "Prochaine prise :",
@@ -1046,6 +1052,8 @@
"subline": "Vérifiez tout ci-dessous avant d’enregistrer.",
"remindersLabel": "Rappels activés",
"remindersDescription": "Push, Telegram ou ntfy — selon vos réglages.",
+ "noChannelHint": "Les rappels nécessitent un canal de notification — aucun n'est actif pour le moment.",
+ "noChannelHintLink": "Configurer les notifications",
"multiScheduleNote": "Ce médicament a plusieurs horaires. L’éditeur détaillé reviendra dans une version ultérieure.",
"short": "Fini"
}
@@ -1282,19 +1290,6 @@
"paused": "En pause",
"ended": "Terminé"
},
- "today": {
- "groupLabel": "Dose du jour",
- "taken": "Pris",
- "skipped": "Sauté",
- "toastTaken": "Dose du jour enregistrée",
- "toastSkipped": "Dose du jour sautée",
- "recordedTaken": "Prise aujourd’hui à {time}",
- "recordedSkipped": "Sautée aujourd’hui à {time}",
- "recordedOneShot": "Dose unique prise à {time}",
- "noneScheduled": "Aucune dose prévue aujourd’hui.",
- "pausedHint": "En pause — pas de rappel aujourd’hui.",
- "error": "Impossible d’enregistrer la dose."
- },
"cadence": {
"oneShotOn": "Dose unique le {date}.",
"oneShotPending": "Dose unique en attente."
@@ -1349,6 +1344,9 @@
"add": "Entrée",
"markTaken": "Prise",
"markSkipped": "Sautée",
+ "rowActionsLabel": "Actions pour {time}",
+ "markAsTaken": "Marquer comme prise",
+ "markAsSkipped": "Marquer comme sautée",
"adHocTag": "hors planning",
"takenDetail": "prévue {planned} · prise {taken}",
"status": {
@@ -1373,6 +1371,18 @@
"body": "Cette prise se situe juste en dehors de la fenêtre de {slot}. La rattacher à ce créneau ou la conserver comme entrée hors planning ?",
"keepAdHoc": "Hors planning",
"pin": "Rattacher"
+ },
+ "pin": {
+ "action": "Rattacher au créneau de {time}",
+ "release": "Détacher du créneau",
+ "pinnedTag": "rattachée",
+ "pinnedToast": "Prise rattachée au créneau",
+ "unpinnedToast": "Prise détachée du créneau",
+ "dueContext": "prévue à {time} ({delta})",
+ "confirmTitle": "Rattacher au créneau de {time} ?",
+ "confirmBody": "La prise comptera comme prise en retard pour ce créneau – le taux n'est pas embelli. Le rattachement peut être retiré à tout moment.",
+ "confirmCancel": "Annuler",
+ "confirmAction": "Rattacher"
}
},
"notifications": {
@@ -1392,8 +1402,8 @@
"requiresCourseWindow": "Les phases sont disponibles une fois une période de traitement définie."
},
"grace": {
- "label": "Fenêtre de rappel — s’applique à votre horaire principal",
- "primaryScheduleNote": "Les médicaments à horaires multiples conservent la fenêtre par défaut de leurs autres horaires.",
+ "label": "Fenêtre de rappel",
+ "appliesToAllNote": "S’applique à tous les horaires de ce médicament.",
"unit": "minutes",
"saved": "Fenêtre de rappel enregistrée",
"failed": "Impossible d’enregistrer la fenêtre de rappel."
@@ -1468,7 +1478,6 @@
"title": "Paramètres avancés",
"group": {
"data": "Données",
- "reminders": "Rappels",
"lifecycle": "Cycle de vie",
"externalApi": "API externe"
},
@@ -1487,8 +1496,7 @@
}
},
"header": {
- "historyLabel": "Voir l'historique des prises",
- "advancedLabel": "Paramètres avancés"
+ "historyLabel": "Voir l'historique des prises"
},
"history": {
"back": "Retour au médicament",
@@ -1498,7 +1506,6 @@
"tabs": {
"uebersicht": "Aperçu",
"zeitplan": "Horaire",
- "erinnerung": "Rappels",
"bestand": "Stock",
"verlauf": "Historique",
"injektion": "Injection",
@@ -1510,6 +1517,17 @@
"tablistLabel": "Sections de détail du médicament"
},
"uebersicht": {
+ "statusTitle": "Statut",
+ "nextDoseLabel": "Prochaine dose",
+ "nextDoseNone": "Aucune dose à venir.",
+ "pausedHint": "En pause — aucun rappel.",
+ "reminderLabel": "Rappel",
+ "reminderOn": "Activé",
+ "reminderOff": "Désactivé",
+ "supplyLabel": "Stock",
+ "supplyRunway": "Couvre encore environ {days} jours",
+ "jumpToZeitplan": "Voir l'horaire",
+ "jumpToBestand": "Voir le stock",
"complianceTitle": "Observance",
"complianceEmpty": "Aucune donnée d'observance pour l'instant."
},
@@ -1542,7 +1560,7 @@
"scheduleLabel": "Horaire {index}",
"fullEditNote": "La fréquence, la classe de traitement et la fenêtre de cure se modifient via « Édition complète ».",
"graceEcho": "Fenêtre de rappel : {minutes} minutes",
- "graceLink": "Modifier dans « Rappels »",
+ "reminderTitle": "Rappel",
"save": "Enregistrer l'horaire",
"saved": "Horaire enregistré",
"failed": "Impossible d'enregistrer l'horaire",
@@ -1573,6 +1591,7 @@
"charts": {
"loadingLabel": "Chargement du graphique…",
"loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.",
+ "adjustTargets": "Ajuster les valeurs cibles",
"weight": "Poids",
"bloodPressure": "Tension artérielle",
"pulse": "Pouls",
@@ -1714,8 +1733,7 @@
"dragHandle": "Fais glisser pour réorganiser",
"show": "Afficher",
"hide": "Masquer",
- "manageSubPages": "Gérer les pages de détail",
- "manageSubPagesHint": "Les pages masquées disparaissent aussi de la navigation supérieure.",
+ "manageInSettings": "Gérer les onglets et les pages de détail dans les réglages",
"groupSleep": "Sommeil",
"groupMood": "Humeur",
"groupEvents": "Événements",
@@ -1843,7 +1861,10 @@
"emptyTitle": "Pas encore de briefing",
"emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.",
"emptyAction": "Générer le briefing",
- "loadingLabel": "Préparation du briefing"
+ "loadingLabel": "Préparation du briefing",
+ "noProviderTitle": "Aucun fournisseur d'IA connecté",
+ "noProviderDescription": "Connecte un fournisseur d'IA dans les réglages pour recevoir des briefings quotidiens.",
+ "noProviderAction": "Ouvrir les réglages IA"
},
"trendsRow": {
"title": "Tendances",
@@ -1936,6 +1957,8 @@
"composerPlaceholder": "Pose-moi une question sur tes données…",
"composerHint": "Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne.",
"composerDisclaimer": "Les réponses sont générées. Les décisions cliniques relèvent de ton médecin.",
+ "selfContextChipsLabel": "Le Coach aimerait savoir :",
+ "selfContextChipDismiss": "Ignorer la question",
"voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.",
"threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.",
"historyTitle": "Conversations",
@@ -2099,6 +2122,8 @@
"navSteps": "Pas",
"navActiveEnergy": "Énergie active",
"navAriaLabel": "Aller à la section",
+ "pillScrollLeft": "Faire défiler les onglets vers la gauche",
+ "pillScrollRight": "Faire défiler les onglets vers la droite",
"tabStrip": {
"vitalsParent": {
"label": "Constantes",
@@ -2301,11 +2326,11 @@
"stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.",
"breathingDisturbancesTitle": "Troubles respiratoires du sommeil",
"breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel.",
- "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied et à laisser votre corps rattraper. Voyez-le comme un repère pour doser la journée, pas comme un verdict.",
- "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre, en pesant la durée de votre sommeil, son efficacité, la régularité de vos horaires et l'équilibre entre sommeil léger, profond et paradoxal. Il vise à montrer d'un coup d'œil si la nuit vous a vraiment restauré. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.",
- "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps et la capacité dont vous disposez aujourd'hui pour encaisser de la charge, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore. C'est un repère sur l'intensité à adopter, pas une limite.",
- "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel et reflète ainsi ce qui est exigeant pour vous en particulier. Servez-vous-en pour équilibrer les journées intenses et les plus douces, et garder un entraînement tenable.",
- "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état plus calme et bien régulé ; un score plus élevé invite à prévoir de la récupération. C'est une approximation descriptive pour repérer des tendances, pas une mesure clinique."
+ "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied.",
+ "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.",
+ "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.",
+ "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.",
+ "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération."
},
"stats": {
"label": "Statistiques récapitulatives",
@@ -3148,7 +3173,7 @@
},
"pillOrder": {
"title": "Trier les onglets de navigation",
- "description": "Faites glisser les onglets dans l'ordre où ils doivent apparaître en haut des Analyses.",
+ "description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil. Les pages de détail masquées disparaissent aussi de la navigation supérieure.",
"dragHandle": "Réorganiser",
"saveSuccess": "Ordre des onglets enregistré.",
"saveError": "L'enregistrement de l'ordre des onglets a échoué.",
@@ -3156,6 +3181,8 @@
"empty": "Aucun onglet de navigation disponible pour le moment."
},
"customize": "Personnaliser les Analyses",
+ "regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.",
+ "regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.",
"regenerateError": "Actualisation en cours — réessayez dans un instant."
},
"thresholds": {
@@ -3382,6 +3409,8 @@
"sources": {
"title": "Priorité des sources",
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
+ "integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.",
+ "integrationsHintLink": "Ouvrir les intégrations",
"cardTitle": "Priorité des sources par métrique",
"help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.",
"resetDefaults": "Rétablir les valeurs par défaut",
@@ -3553,6 +3582,15 @@
"profile": "Profil",
"language": "Langue",
"languageDescription": "Choisissez la langue d’affichage de l’application.",
+ "timeFormat": {
+ "title": "Format horaire",
+ "description": "Détermine si les heures s’affichent au format 24 heures ou 12 heures (AM/PM).",
+ "auto": "Automatique (langue)",
+ "h24": "24 heures",
+ "h12": "12 heures (AM/PM)",
+ "saved": "Format horaire enregistré.",
+ "saveError": "Échec de l’enregistrement du format horaire."
+ },
"username": "Nom d'utilisateur",
"height": "Taille (cm)",
"dateOfBirth": "Date de naissance",
@@ -3642,8 +3680,8 @@
"codexDisconnected": "Connexion à ChatGPT supprimée.",
"codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.",
"rawData": "Envoyer les données brutes",
- "rawDataOnDescription": "Des métriques agrégées sont envoyées, ainsi que des points bruts anonymisés des 30 derniers jours. Cela inclut des mesures par métrique (par exemple poids, tension, pouls) avec un contexte temporel, afin que le fournisseur puisse détecter plus fiablement les tendances, les valeurs aberrantes et les corrélations. Le nom, l’e-mail et les identifiants de compte directs ne sont pas transmis.",
- "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum). Aucun point individuel ni contexte temporel exact. Cela préserve mieux la vie privée, mais le fournisseur peut être moins précis pour les tendances et fluctuations à court terme.",
+ "rawDataOnDescription": "Des métriques agrégées et des points individuels anonymisés des 30 derniers jours avec contexte temporel sont envoyés — jamais le nom, l’e-mail ni les identifiants de compte directs.",
+ "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum) — aucun point individuel ni contexte temporel exact.",
"rawDataWarning": "Mode brut activé : le fournisseur reçoit des points anonymisés supplémentaires des 30 derniers jours pour une analyse plus précise.",
"regenerateInsights": "Régénérer les rapports",
"regenerateSuccess": "Rapports régénérés",
@@ -3775,6 +3813,30 @@
"savedHidden": "Coach masqué.",
"savedShown": "Coach visible.",
"saveError": "Impossible d'enregistrer la modification. Veuillez réessayer."
+ },
+ "aboutMe": {
+ "title": "À propos de moi",
+ "description": "Contexte personnel que le Coach et le briefing quotidien lisent en plus de vos données.",
+ "placeholder": "Ex. travail posté, régime végétarien, blessure au genou, entraînement pour un semi-marathon…",
+ "charCount": "{used} / {max} caractères",
+ "clear": "Vider",
+ "save": "Enregistrer",
+ "savedToast": "Note « À propos de moi » enregistrée.",
+ "clearedToast": "Note « À propos de moi » supprimée.",
+ "saveError": "Impossible d'enregistrer. Veuillez réessayer.",
+ "loadError": "Impossible de charger la note.",
+ "hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer.",
+ "profileHint": "L'âge et le genre proviennent de votre profil et sont inclus automatiquement.",
+ "freeTextLabel": "À propos de moi (texte libre)",
+ "conditionsLabel": "Maladies chroniques",
+ "conditionsPlaceholder": "P. ex. hypertension, diabète de type 2…",
+ "allergiesLabel": "Allergies et intolérances",
+ "allergiesPlaceholder": "P. ex. intolérance au lactose, allergie à la pénicilline…",
+ "focusLabel": "À quoi le Coach doit-il veiller ?",
+ "focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…",
+ "completeness": "{answered} sur {total} renseignés",
+ "questionsTitle": "Le Coach a des questions",
+ "questionsOpenCoach": "Y répondre dans le Coach"
}
},
"dashboard": {
@@ -3804,6 +3866,21 @@
"withingsSyncResult": "{count} mesures synchronisées",
"withingsFullSyncResult": "{count} mesures entièrement synchronisées",
"withingsSyncFailed": "Échec de la synchronisation",
+ "withingsOauthConnected": "Withings connecté — la première synchronisation démarre en arrière-plan.",
+ "withingsOauthFailed": "Échec de la connexion Withings",
+ "withingsOauthError": {
+ "csrf1": "Le contrôle de sécurité a échoué car le cookie du navigateur manquait. Relancez la connexion dans le même navigateur.",
+ "replay": "Ce lien de confirmation a déjà été utilisé. Relancez la connexion.",
+ "state": "La réponse de Withings ne correspond à aucune demande de connexion en cours. Relancez la connexion.",
+ "expired": "La demande de connexion a expiré. Relancez la connexion et terminez l'autorisation en quelques minutes.",
+ "cross_user": "La réponse appartient à une demande d'un autre compte. Relancez la connexion depuis votre propre compte.",
+ "nocode": "Withings n'a renvoyé aucun code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Withings.",
+ "nocreds": "Aucun identifiant d'API Withings n'est enregistré. Enregistrez d'abord le Client ID et le Client Secret, puis reconnectez.",
+ "token": "L'échange de jetons avec Withings a échoué. Vérifiez le Client ID et le Client Secret, puis réessayez.",
+ "generic": "Erreur inconnue lors de la connexion. Relancez la connexion."
+ },
+ "integrationsSourcesHint": "Quand plusieurs sources fournissent la même métrique, la priorité des sources décide quelle valeur compte.",
+ "integrationsSourcesHintLink": "Ouvrir la priorité des sources",
"withingsSynchronize": "Synchroniser",
"withingsDisconnect": "Déconnecter",
"withingsDisconnectTitle": "Déconnecter Withings ?",
@@ -4240,6 +4317,55 @@
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Jeton d'issue",
"bugReportTokenPlaceholder": "ghp_...",
+ "invites": {
+ "title": "Invitations",
+ "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.",
+ "adminOnlyHint": "Seuls les administrateurs peuvent créer et révoquer des invitations.",
+ "create": "Créer une invitation",
+ "createTitle": "Créer une invitation",
+ "createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.",
+ "ttlLegend": "Valable pendant",
+ "createDays": "{days} jours",
+ "maxUsesLabel": "Utilisations maximales",
+ "maxUsesHint": "Nombre d'inscriptions que ce lien autorise (1–50).",
+ "createConfirm": "Créer l'invitation",
+ "createdToast": "Invitation créée.",
+ "createError": "Impossible de créer l'invitation.",
+ "tokenTitle": "Lien d'invitation",
+ "tokenOnceHint": "Copiable uniquement maintenant — copiez le lien ou faites scanner le code QR.",
+ "hashExplainer": "Seul un hachage du lien est stocké : il ne pourra plus être affiché. En cas de perte, révoquez l'invitation et créez-en une nouvelle.",
+ "copyLink": "Copier le lien",
+ "copiedToast": "Lien copié.",
+ "copyError": "Échec de la copie.",
+ "qrAlt": "Code QR du lien d'invitation",
+ "loadError": "Impossible de charger les invitations.",
+ "emptyTitle": "Aucune invitation pour l'instant",
+ "emptyDescription": "Créez un lien d'invitation pour admettre quelqu'un malgré des inscriptions fermées.",
+ "colStatus": "Statut",
+ "colCreated": "Créée",
+ "colExpires": "Expire",
+ "colUses": "Utilisations",
+ "colRedeemedBy": "Utilisée par",
+ "colActions": "Actions",
+ "byCreator": "par {by}",
+ "expiresInDaysOne": "dans 1 jour",
+ "expiresInDaysOther": "dans {count} jours",
+ "statusActive": "Active",
+ "statusExpired": "Expirée",
+ "statusExhausted": "Épuisée",
+ "statusRevoked": "Révoquée",
+ "redeemerDeleted": "compte supprimé",
+ "redeemedByMore": "{username} + {count} autres",
+ "redemptionsTitle": "{count} utilisations",
+ "revoke": "Révoquer",
+ "revokeAria": "Révoquer l'invitation",
+ "revokeConfirmTitle": "Révoquer cette invitation ?",
+ "revokeConfirmBody": "Le lien d'invitation devient immédiatement invalide. Les comptes déjà créés avec ce lien sont conservés et l'historique des utilisations reste visible.",
+ "revokeConfirmAction": "Révoquer",
+ "cancel": "Annuler",
+ "revokedToast": "Invitation révoquée.",
+ "revokeError": "Impossible de révoquer l'invitation."
+ },
"userManagement": "Gestion des utilisateurs",
"userName": "Nom",
"userEmail": "E-mail",
@@ -4458,6 +4584,10 @@
"emptyDescription": "Aucun utilisateur ne correspond au filtre actuel.",
"emptyResetFilter": "Afficher tous les utilisateurs"
},
+ "invites": {
+ "title": "Invitations",
+ "subtitle": "Créer, suivre et révoquer les liens d'invitation."
+ },
"api-tokens": {
"title": "Jetons d'API",
"subtitle": "Jetons bearer actifs émis aux clients natifs et aux intégrations."
@@ -4903,6 +5033,16 @@
"hourAria": "Heure du rappel d’humeur",
"hourSavedToast": "Heure du rappel mise à jour."
},
+ "coachNudge": {
+ "title": "Coach proactif",
+ "description": "Vous contacte au plus une fois par semaine lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée.",
+ "toggleAria": "Activer ou désactiver les rappels proactifs du Coach",
+ "statusOn": "Actif — max. 1 fois par semaine",
+ "statusOff": "Désactivé",
+ "enabledToast": "Rappels proactifs du Coach activés.",
+ "disabledToast": "Rappels proactifs du Coach désactivés.",
+ "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard."
+ },
"eventMedicationReminder": "Rappel de médicament",
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
"eventMeasurementAnomaly": "Mesure inhabituelle",
@@ -5089,6 +5229,20 @@
"dailyTitle": "Enregistrer votre humeur",
"dailyBody": "Comment vous sentez-vous aujourd’hui ?"
},
+ "coachNudges": {
+ "complianceTitle": "Votre Coach a une remarque à ce sujet",
+ "complianceBody": "Vos prises ont été irrégulières cette semaine. Échangez avec le Coach — le plan peut peut-être être simplifié.",
+ "bpTitle": "Tension élevée cette semaine",
+ "bpBody": "Votre tension moyenne de la semaine a dépassé votre objectif. Le Coach peut vous aider à mettre les valeurs en perspective.",
+ "scoreTitle": "Votre score de récupération baisse",
+ "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.",
+ "selfContextTitle": "Aidez votre Coach à mieux vous connaître",
+ "selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.",
+ "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?",
+ "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?",
+ "questionAllergies": "Avez-vous des allergies ou des intolérances ?",
+ "questionAboutMe": "Envie de parler un peu de vous au Coach — habitudes, objectifs, contexte ?"
+ },
"clinicianView": {
"title": "Dossier de santé partagé",
"period": "Période couverte : {start} – {end}",
diff --git a/messages/it.json b/messages/it.json
index b56733170..56308a079 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -192,6 +192,9 @@
"offlineBanner": {
"message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online."
},
+ "demoBanner": {
+ "message": "Modalità demo — le modifiche non vengono salvate."
+ },
"nav": {
"dashboard": "Dashboard",
"measurements": "Misurazioni",
@@ -251,6 +254,7 @@
"hasAccount": "Hai già un account?",
"loginFailed": "Accesso non riuscito",
"registerFailed": "Registrazione non riuscita",
+ "invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.",
"createAccount": "Crea account",
"newAccount": "Crea un nuovo account",
"backToPasskey": "Torna a passkey",
@@ -719,6 +723,8 @@
"compliance7d": "Aderenza 7 giorni",
"compliance30d": "Aderenza 30 giorni",
"complianceWindow": "Aderenza {days} giorni",
+ "complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.",
+ "complianceInfoLabel": "Cosa significa aderenza?",
"complianceDoses": "{count} dosi",
"lastIntake": "Ultima assunzione:",
"nextIntake": "Prossima assunzione:",
@@ -1046,6 +1052,8 @@
"subline": "Controlla tutto qui sotto prima di salvare.",
"remindersLabel": "Promemoria attivi",
"remindersDescription": "Push, Telegram o ntfy — in base alle tue impostazioni.",
+ "noChannelHint": "I promemoria richiedono un canale di notifica — al momento nessuno è attivo.",
+ "noChannelHintLink": "Configura le notifiche",
"multiScheduleNote": "Questo farmaco ha più orari. L’editor dettagliato tornerà in una versione successiva.",
"short": "Fatto"
}
@@ -1282,19 +1290,6 @@
"paused": "In pausa",
"ended": "Terminato"
},
- "today": {
- "groupLabel": "Dose di oggi",
- "taken": "Assunta",
- "skipped": "Saltata",
- "toastTaken": "Dose di oggi registrata",
- "toastSkipped": "Dose di oggi saltata",
- "recordedTaken": "Assunta oggi alle {time}",
- "recordedSkipped": "Saltata oggi alle {time}",
- "recordedOneShot": "Dose singola assunta alle {time}",
- "noneScheduled": "Nessuna dose prevista per oggi.",
- "pausedHint": "In pausa — nessun promemoria oggi.",
- "error": "Impossibile registrare la dose."
- },
"cadence": {
"oneShotOn": "Dose singola il {date}.",
"oneShotPending": "Dose singola in sospeso."
@@ -1349,6 +1344,9 @@
"add": "Voce",
"markTaken": "Assunta",
"markSkipped": "Saltata",
+ "rowActionsLabel": "Azioni per {time}",
+ "markAsTaken": "Segna come assunta",
+ "markAsSkipped": "Segna come saltata",
"adHocTag": "fuori programma",
"takenDetail": "prevista {planned} · assunta {taken}",
"status": {
@@ -1373,6 +1371,18 @@
"body": "Questa assunzione cade appena fuori dalla finestra di {slot}. Attribuirla a quello slot o tenerla come voce fuori programma?",
"keepAdHoc": "Fuori programma",
"pin": "Attribuisci"
+ },
+ "pin": {
+ "action": "Attribuisci allo slot delle {time}",
+ "release": "Rimuovi attribuzione",
+ "pinnedTag": "attribuita",
+ "pinnedToast": "Assunzione attribuita allo slot",
+ "unpinnedToast": "Attribuzione rimossa",
+ "dueContext": "prevista alle {time} ({delta})",
+ "confirmTitle": "Attribuire allo slot delle {time}?",
+ "confirmBody": "L'assunzione conterà come in ritardo per questo slot – la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.",
+ "confirmCancel": "Annulla",
+ "confirmAction": "Attribuisci"
}
},
"notifications": {
@@ -1392,8 +1402,8 @@
"requiresCourseWindow": "Le fasi sono disponibili dopo aver impostato un periodo di trattamento."
},
"grace": {
- "label": "Finestra di promemoria — si applica al tuo programma principale",
- "primaryScheduleNote": "I farmaci con più programmi mantengono la finestra predefinita degli altri programmi.",
+ "label": "Finestra di promemoria",
+ "appliesToAllNote": "Vale per tutti i programmi di questo farmaco.",
"unit": "minuti",
"saved": "Finestra di promemoria salvata",
"failed": "Impossibile salvare la finestra di promemoria."
@@ -1468,7 +1478,6 @@
"title": "Impostazioni avanzate",
"group": {
"data": "Dati",
- "reminders": "Promemoria",
"lifecycle": "Ciclo di vita",
"externalApi": "API esterna"
},
@@ -1487,8 +1496,7 @@
}
},
"header": {
- "historyLabel": "Visualizza cronologia assunzioni",
- "advancedLabel": "Impostazioni avanzate"
+ "historyLabel": "Visualizza cronologia assunzioni"
},
"history": {
"back": "Torna al farmaco",
@@ -1498,7 +1506,6 @@
"tabs": {
"uebersicht": "Panoramica",
"zeitplan": "Programma",
- "erinnerung": "Promemoria",
"bestand": "Scorta",
"verlauf": "Cronologia",
"injektion": "Iniezione",
@@ -1510,6 +1517,17 @@
"tablistLabel": "Sezioni di dettaglio del farmaco"
},
"uebersicht": {
+ "statusTitle": "Stato",
+ "nextDoseLabel": "Prossima dose",
+ "nextDoseNone": "Nessuna dose in programma.",
+ "pausedHint": "In pausa — nessun promemoria.",
+ "reminderLabel": "Promemoria",
+ "reminderOn": "Attivo",
+ "reminderOff": "Disattivo",
+ "supplyLabel": "Scorta",
+ "supplyRunway": "Copre ancora circa {days} giorni",
+ "jumpToZeitplan": "Apri programma",
+ "jumpToBestand": "Apri scorta",
"complianceTitle": "Aderenza",
"complianceEmpty": "Ancora nessun dato sull'aderenza."
},
@@ -1542,7 +1560,7 @@
"scheduleLabel": "Programma {index}",
"fullEditNote": "Frequenza, classe di trattamento e finestra del ciclo si modificano da «Modifica completa».",
"graceEcho": "Finestra di promemoria: {minutes} minuti",
- "graceLink": "Modifica in «Promemoria»",
+ "reminderTitle": "Promemoria",
"save": "Salva programma",
"saved": "Programma salvato",
"failed": "Impossibile salvare il programma",
@@ -1573,6 +1591,7 @@
"charts": {
"loadingLabel": "Caricamento del grafico…",
"loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.",
+ "adjustTargets": "Regola i valori target",
"weight": "Peso",
"bloodPressure": "Pressione arteriosa",
"pulse": "Pulsazioni",
@@ -1714,8 +1733,7 @@
"dragHandle": "Trascina per riordinare",
"show": "Mostra",
"hide": "Nascondi",
- "manageSubPages": "Gestisci pagine di dettaglio",
- "manageSubPagesHint": "Le pagine nascoste vengono rimosse anche dalla navigazione in alto.",
+ "manageInSettings": "Gestisci schede e pagine di dettaglio nelle impostazioni",
"groupSleep": "Sonno",
"groupMood": "Umore",
"groupEvents": "Eventi",
@@ -1843,7 +1861,10 @@
"emptyTitle": "Ancora nessun briefing",
"emptyDescription": "Genera il briefing di oggi per avere un riepilogo dei tuoi trend.",
"emptyAction": "Genera briefing",
- "loadingLabel": "Preparazione del briefing"
+ "loadingLabel": "Preparazione del briefing",
+ "noProviderTitle": "Nessun provider IA collegato",
+ "noProviderDescription": "Collega un provider IA nelle impostazioni per ricevere briefing giornalieri.",
+ "noProviderAction": "Apri impostazioni IA"
},
"trendsRow": {
"title": "Tendenze",
@@ -1936,6 +1957,8 @@
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
"composerHint": "Invio per inviare, Shift+Invio per una nuova riga.",
"composerDisclaimer": "Le risposte sono generate. Le decisioni cliniche restano al tuo medico.",
+ "selfContextChipsLabel": "Il Coach vorrebbe sapere:",
+ "selfContextChipDismiss": "Ignora la domanda",
"voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.",
"threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.",
"historyTitle": "Conversazioni",
@@ -2099,6 +2122,8 @@
"navSteps": "Passi",
"navActiveEnergy": "Energia attiva",
"navAriaLabel": "Vai alla sezione",
+ "pillScrollLeft": "Scorri le schede a sinistra",
+ "pillScrollRight": "Scorri le schede a destra",
"tabStrip": {
"vitalsParent": {
"label": "Vitali",
@@ -2301,11 +2326,11 @@
"stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.",
"breathingDisturbancesTitle": "Disturbi respiratori nel sonno",
"breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico.",
- "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare e lasciare che il corpo recuperi. Consideralo una guida per dosare la giornata, non un verdetto.",
- "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero, valutando quanto hai dormito, con quale efficienza, quanto regolari sono stati i tuoi orari e l'equilibrio tra sonno leggero, profondo e REM. Serve a mostrare a colpo d'occhio se la notte ti ha davvero ristorato. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.",
- "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso e quanta capacità hai oggi per sostenere il carico, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo. È una guida su quanto impegnarti, non un limite.",
- "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale e riflette così ciò che è impegnativo per te in particolare. Usalo per bilanciare le giornate intense con altre più leggere e mantenere un allenamento sostenibile.",
- "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato più calmo e ben regolato; uno più alto è un invito a inserire del recupero. È un'approssimazione descrittiva per notare le tendenze, non una misurazione clinica."
+ "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare.",
+ "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.",
+ "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.",
+ "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.",
+ "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero."
},
"stats": {
"label": "Statistiche riepilogative",
@@ -3148,7 +3173,7 @@
},
"pillOrder": {
"title": "Ordina le schede di navigazione",
- "description": "Trascina le schede nell'ordine in cui devono comparire in alto negli Approfondimenti.",
+ "description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio. Le pagine di dettaglio nascoste vengono rimosse anche dalla navigazione in alto.",
"dragHandle": "Riordina",
"saveSuccess": "Ordine delle schede salvato.",
"saveError": "Salvataggio dell'ordine delle schede non riuscito.",
@@ -3156,6 +3181,8 @@
"empty": "Nessuna scheda di navigazione ancora disponibile."
},
"customize": "Personalizza gli Approfondimenti",
+ "regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.",
+ "regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.",
"regenerateError": "Aggiornamento ancora in corso — riprova tra un momento."
},
"thresholds": {
@@ -3382,6 +3409,8 @@
"sources": {
"title": "Priorità delle fonti",
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
+ "integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.",
+ "integrationsHintLink": "Apri le integrazioni",
"cardTitle": "Priorità delle fonti per metrica",
"help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.",
"resetDefaults": "Ripristina i valori predefiniti",
@@ -3553,6 +3582,15 @@
"profile": "Profilo",
"language": "Lingua",
"languageDescription": "Scegli la lingua di visualizzazione dell’applicazione.",
+ "timeFormat": {
+ "title": "Formato orario",
+ "description": "Determina se gli orari vengono mostrati nel formato 24 ore o 12 ore (AM/PM).",
+ "auto": "Automatico (lingua)",
+ "h24": "24 ore",
+ "h12": "12 ore (AM/PM)",
+ "saved": "Formato orario salvato.",
+ "saveError": "Salvataggio del formato orario non riuscito."
+ },
"username": "Nome utente",
"height": "Altezza (cm)",
"dateOfBirth": "Data di nascita",
@@ -3642,8 +3680,8 @@
"codexDisconnected": "Connessione a ChatGPT rimossa.",
"codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.",
"rawData": "Invia dati grezzi",
- "rawDataOnDescription": "Vengono inviate metriche aggregate più punti grezzi anonimizzati degli ultimi 30 giorni. Ciò include misurazioni per singola metrica (ad esempio peso, pressione, polso) con contesto temporale, in modo che il provider possa rilevare in modo più affidabile schemi, valori anomali e correlazioni. Nome, e-mail e identificatori diretti dell’account non vengono trasmessi.",
- "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo). Nessun punto individuale e nessun contesto temporale preciso. Questo tutela meglio la privacy, ma il provider può essere meno preciso su schemi e oscillazioni a breve termine.",
+ "rawDataOnDescription": "Vengono inviate metriche aggregate più punti individuali anonimizzati degli ultimi 30 giorni con contesto temporale — mai nome, e-mail o identificatori diretti dell’account.",
+ "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo) — nessun punto individuale e nessun contesto temporale preciso.",
"rawDataWarning": "Modalità grezza attiva: il provider riceve ulteriori punti anonimizzati degli ultimi 30 giorni per un’analisi più precisa.",
"regenerateInsights": "Rigenera i report",
"regenerateSuccess": "Report rigenerati correttamente",
@@ -3775,6 +3813,30 @@
"savedHidden": "Coach nascosto.",
"savedShown": "Coach visibile.",
"saveError": "Impossibile salvare la modifica. Riprova."
+ },
+ "aboutMe": {
+ "title": "Su di me",
+ "description": "Contesto personale che il Coach e il briefing giornaliero leggono insieme ai tuoi dati.",
+ "placeholder": "Es. lavoro a turni, dieta vegetariana, infortunio al ginocchio, allenamento per una mezza maratona…",
+ "charCount": "{used} / {max} caratteri",
+ "clear": "Svuota",
+ "save": "Salva",
+ "savedToast": "Nota «Su di me» salvata.",
+ "clearedToast": "Nota «Su di me» eliminata.",
+ "saveError": "Impossibile salvare. Riprova.",
+ "loadError": "Impossibile caricare la nota.",
+ "hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla.",
+ "profileHint": "Età e genere provengono dal tuo profilo e vengono inclusi automaticamente.",
+ "freeTextLabel": "Su di me (testo libero)",
+ "conditionsLabel": "Malattie croniche",
+ "conditionsPlaceholder": "Es. ipertensione, diabete di tipo 2…",
+ "allergiesLabel": "Allergie e intolleranze",
+ "allergiesPlaceholder": "Es. intolleranza al lattosio, allergia alla penicillina…",
+ "focusLabel": "A cosa deve fare attenzione il Coach?",
+ "focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…",
+ "completeness": "{answered} su {total} compilati",
+ "questionsTitle": "Il Coach ha alcune domande",
+ "questionsOpenCoach": "Rispondi nel Coach"
}
},
"dashboard": {
@@ -3804,6 +3866,21 @@
"withingsSyncResult": "{count} misurazioni sincronizzate",
"withingsFullSyncResult": "{count} misurazioni sincronizzate completamente",
"withingsSyncFailed": "Sincronizzazione non riuscita",
+ "withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.",
+ "withingsOauthFailed": "Collegamento a Withings non riuscito",
+ "withingsOauthError": {
+ "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Avvia di nuovo il collegamento nello stesso browser.",
+ "replay": "Questo link di conferma è già stato utilizzato. Avvia di nuovo il collegamento.",
+ "state": "La risposta di Withings non corrisponde a nessuna richiesta di collegamento aperta. Avvia di nuovo il collegamento.",
+ "expired": "La richiesta di collegamento è scaduta. Avvia di nuovo il collegamento e completa l'autorizzazione entro pochi minuti.",
+ "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia il collegamento dal tuo account.",
+ "nocode": "Withings non ha restituito alcun codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Withings.",
+ "nocreds": "Non sono salvate credenziali API di Withings. Salva prima Client ID e Client Secret, poi ricollega.",
+ "token": "Lo scambio di token con Withings non è riuscito. Controlla Client ID e Client Secret e riprova.",
+ "generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento."
+ },
+ "integrationsSourcesHint": "Se più fonti forniscono la stessa metrica, la priorità delle fonti decide quale valore conta.",
+ "integrationsSourcesHintLink": "Apri la priorità delle fonti",
"withingsSynchronize": "Sincronizza",
"withingsDisconnect": "Disconnetti",
"withingsDisconnectTitle": "Disconnettere Withings?",
@@ -4240,6 +4317,55 @@
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Token delle issue",
"bugReportTokenPlaceholder": "ghp_...",
+ "invites": {
+ "title": "Inviti",
+ "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.",
+ "adminOnlyHint": "Solo gli amministratori possono creare e revocare gli inviti.",
+ "create": "Crea invito",
+ "createTitle": "Crea invito",
+ "createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.",
+ "ttlLegend": "Valido per",
+ "createDays": "{days} giorni",
+ "maxUsesLabel": "Utilizzi massimi",
+ "maxUsesHint": "Quante registrazioni consente questo link (1–50).",
+ "createConfirm": "Crea invito",
+ "createdToast": "Invito creato.",
+ "createError": "Impossibile creare l'invito.",
+ "tokenTitle": "Link di invito",
+ "tokenOnceHint": "Copiabile solo adesso: copia il link o fai scansionare il codice QR.",
+ "hashExplainer": "Viene salvato solo un hash del link: non potrà più essere mostrato. Se va perso, revoca l'invito e creane uno nuovo.",
+ "copyLink": "Copia link",
+ "copiedToast": "Link copiato.",
+ "copyError": "Copia non riuscita.",
+ "qrAlt": "Codice QR del link di invito",
+ "loadError": "Impossibile caricare gli inviti.",
+ "emptyTitle": "Ancora nessun invito",
+ "emptyDescription": "Crea un link di invito per ammettere qualcuno nonostante la registrazione chiusa.",
+ "colStatus": "Stato",
+ "colCreated": "Creato",
+ "colExpires": "Scade",
+ "colUses": "Utilizzi",
+ "colRedeemedBy": "Riscattato da",
+ "colActions": "Azioni",
+ "byCreator": "da {by}",
+ "expiresInDaysOne": "tra 1 giorno",
+ "expiresInDaysOther": "tra {count} giorni",
+ "statusActive": "Attivo",
+ "statusExpired": "Scaduto",
+ "statusExhausted": "Esaurito",
+ "statusRevoked": "Revocato",
+ "redeemerDeleted": "account eliminato",
+ "redeemedByMore": "{username} + altri {count}",
+ "redemptionsTitle": "{count} riscatti",
+ "revoke": "Revoca",
+ "revokeAria": "Revoca invito",
+ "revokeConfirmTitle": "Revocare questo invito?",
+ "revokeConfirmBody": "Il link di invito diventa subito non valido. Gli account già registrati con questo link restano e la cronologia dei riscatti resta visibile.",
+ "revokeConfirmAction": "Revoca",
+ "cancel": "Annulla",
+ "revokedToast": "Invito revocato.",
+ "revokeError": "Impossibile revocare l'invito."
+ },
"userManagement": "Gestione utenti",
"userName": "Nome",
"userEmail": "E-mail",
@@ -4458,6 +4584,10 @@
"emptyDescription": "Nessun utente corrisponde al filtro attuale.",
"emptyResetFilter": "Mostra tutti gli utenti"
},
+ "invites": {
+ "title": "Inviti",
+ "subtitle": "Creare, monitorare e revocare i link di invito."
+ },
"api-tokens": {
"title": "Token API",
"subtitle": "Token bearer attivi emessi a client nativi e integrazioni."
@@ -4903,6 +5033,16 @@
"hourAria": "Ora del promemoria dell’umore",
"hourSavedToast": "Ora del promemoria aggiornata."
},
+ "coachNudge": {
+ "title": "Coach proattivo",
+ "description": "Si fa sentire al massimo una volta a settimana quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata.",
+ "toggleAria": "Attiva o disattiva gli avvisi proattivi del Coach",
+ "statusOn": "Attivo — max 1 volta a settimana",
+ "statusOff": "Disattivato",
+ "enabledToast": "Avvisi proattivi del Coach attivati.",
+ "disabledToast": "Avvisi proattivi del Coach disattivati.",
+ "saveError": "Impossibile salvare. Riprova più tardi."
+ },
"eventMedicationReminder": "Promemoria farmaco",
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
"eventMeasurementAnomaly": "Misurazione insolita",
@@ -5089,6 +5229,20 @@
"dailyTitle": "Registra il tuo umore",
"dailyBody": "Come ti senti oggi?"
},
+ "coachNudges": {
+ "complianceTitle": "Il tuo Coach ha un'osservazione",
+ "complianceBody": "Le tue assunzioni sono state discontinue questa settimana. Parla un attimo con il Coach: forse il piano si può semplificare.",
+ "bpTitle": "Pressione elevata questa settimana",
+ "bpBody": "La tua pressione media di questa settimana ha superato il tuo obiettivo. Il Coach può aiutarti a contestualizzare i valori.",
+ "scoreTitle": "Il tuo punteggio di recupero sta calando",
+ "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.",
+ "selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio",
+ "selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.",
+ "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?",
+ "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?",
+ "questionAllergies": "Hai allergie o intolleranze?",
+ "questionAboutMe": "Vuoi raccontare al Coach qualcosa di te — abitudini, obiettivi, contesto?"
+ },
"clinicianView": {
"title": "Cartella sanitaria condivisa",
"period": "Periodo del report: {start} – {end}",
diff --git a/messages/pl.json b/messages/pl.json
index 71c5b8d23..34daaf1f3 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -192,6 +192,9 @@
"offlineBanner": {
"message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online."
},
+ "demoBanner": {
+ "message": "Tryb demo — zmiany nie są zapisywane."
+ },
"nav": {
"dashboard": "Panel",
"measurements": "Pomiary",
@@ -251,6 +254,7 @@
"hasAccount": "Masz już konto?",
"loginFailed": "Logowanie nie powiodło się",
"registerFailed": "Rejestracja nie powiodła się",
+ "invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.",
"createAccount": "Utwórz konto",
"newAccount": "Utwórz nowe konto",
"backToPasskey": "Wróć do passkey",
@@ -719,6 +723,8 @@
"compliance7d": "Przestrzeganie 7 dni",
"compliance30d": "Przestrzeganie 30 dni",
"complianceWindow": "Przestrzeganie {days} dni",
+ "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.",
+ "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?",
"complianceDoses": "{count} dawek",
"lastIntake": "Ostatnie przyjęcie:",
"nextIntake": "Następne przyjęcie:",
@@ -1046,6 +1052,8 @@
"subline": "Sprawdź wszystko poniżej przed zapisaniem.",
"remindersLabel": "Przypomnienia włączone",
"remindersDescription": "Push, Telegram lub ntfy — w zależności od ustawień.",
+ "noChannelHint": "Przypomnienia wymagają kanału powiadomień — obecnie żaden nie jest aktywny.",
+ "noChannelHintLink": "Skonfiguruj powiadomienia",
"multiScheduleNote": "Ten lek ma wiele harmonogramów. Szczegółowy edytor wróci w kolejnej wersji.",
"short": "Gotowe"
}
@@ -1282,19 +1290,6 @@
"paused": "Wstrzymany",
"ended": "Zakończony"
},
- "today": {
- "groupLabel": "Dzisiejsza dawka",
- "taken": "Przyjęto",
- "skipped": "Pominięto",
- "toastTaken": "Dzisiejsza dawka zapisana",
- "toastSkipped": "Dzisiejsza dawka pominięta",
- "recordedTaken": "Przyjęto dziś o {time}",
- "recordedSkipped": "Pominięto dziś o {time}",
- "recordedOneShot": "Jednorazową dawkę przyjęto o {time}",
- "noneScheduled": "Na dziś nie zaplanowano żadnej dawki.",
- "pausedHint": "Wstrzymano — dziś brak przypomnienia.",
- "error": "Nie udało się zapisać dawki."
- },
"cadence": {
"oneShotOn": "Dawka jednorazowa w dniu {date}.",
"oneShotPending": "Dawka jednorazowa oczekuje."
@@ -1349,6 +1344,9 @@
"add": "Wpis",
"markTaken": "Przyjęte",
"markSkipped": "Pominięte",
+ "rowActionsLabel": "Akcje dla {time}",
+ "markAsTaken": "Oznacz jako przyjętą",
+ "markAsSkipped": "Oznacz jako pominiętą",
"adHocTag": "poza planem",
"takenDetail": "planowo {planned} · przyjęto {taken}",
"status": {
@@ -1373,6 +1371,18 @@
"body": "To przyjęcie wypada tuż poza oknem {slot}. Przypisać je do tego slotu czy zachować jako wpis poza planem?",
"keepAdHoc": "Poza planem",
"pin": "Przypisz"
+ },
+ "pin": {
+ "action": "Przypisz do slotu {time}",
+ "release": "Usuń przypisanie",
+ "pinnedTag": "przypisana",
+ "pinnedToast": "Dawka przypisana do slotu",
+ "unpinnedToast": "Przypisanie usunięte",
+ "dueContext": "termin: {time} ({delta})",
+ "confirmTitle": "Przypisać do slotu {time}?",
+ "confirmBody": "Dawka zostanie policzona jako przyjęta z opóźnieniem dla tego slotu – wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.",
+ "confirmCancel": "Anuluj",
+ "confirmAction": "Przypisz"
}
},
"notifications": {
@@ -1392,8 +1402,8 @@
"requiresCourseWindow": "Fazy są dostępne po ustawieniu okresu leczenia."
},
"grace": {
- "label": "Okno przypomnienia — dotyczy głównego harmonogramu",
- "primaryScheduleNote": "Leki z wieloma harmonogramami zachowują domyślne okno pozostałych harmonogramów.",
+ "label": "Okno przypomnienia",
+ "appliesToAllNote": "Dotyczy wszystkich harmonogramów tego leku.",
"unit": "minut",
"saved": "Okno przypomnienia zapisane",
"failed": "Nie udało się zapisać okna przypomnienia."
@@ -1468,7 +1478,6 @@
"title": "Ustawienia zaawansowane",
"group": {
"data": "Dane",
- "reminders": "Przypomnienia",
"lifecycle": "Cykl życia",
"externalApi": "Zewnętrzne API"
},
@@ -1487,8 +1496,7 @@
}
},
"header": {
- "historyLabel": "Zobacz historię przyjęć",
- "advancedLabel": "Ustawienia zaawansowane"
+ "historyLabel": "Zobacz historię przyjęć"
},
"history": {
"back": "Powrót do leku",
@@ -1498,7 +1506,6 @@
"tabs": {
"uebersicht": "Przegląd",
"zeitplan": "Harmonogram",
- "erinnerung": "Przypomnienia",
"bestand": "Zapas",
"verlauf": "Historia",
"injektion": "Iniekcja",
@@ -1510,6 +1517,17 @@
"tablistLabel": "Sekcje szczegółów leku"
},
"uebersicht": {
+ "statusTitle": "Status",
+ "nextDoseLabel": "Następna dawka",
+ "nextDoseNone": "Brak nadchodzącej dawki.",
+ "pausedHint": "Wstrzymano — brak przypomnień.",
+ "reminderLabel": "Przypomnienie",
+ "reminderOn": "Włączone",
+ "reminderOff": "Wyłączone",
+ "supplyLabel": "Zapas",
+ "supplyRunway": "Wystarczy na około {days} dni",
+ "jumpToZeitplan": "Otwórz harmonogram",
+ "jumpToBestand": "Otwórz zapas",
"complianceTitle": "Przestrzeganie",
"complianceEmpty": "Brak danych o przestrzeganiu."
},
@@ -1542,7 +1560,7 @@
"scheduleLabel": "Harmonogram {index}",
"fullEditNote": "Częstotliwość, klasę leczenia i okno kuracji edytujesz przez „Pełna edycja”.",
"graceEcho": "Okno przypomnienia: {minutes} minut",
- "graceLink": "Zmień w „Przypomnienia”",
+ "reminderTitle": "Przypomnienie",
"save": "Zapisz harmonogram",
"saved": "Zapisano harmonogram",
"failed": "Nie udało się zapisać harmonogramu",
@@ -1573,6 +1591,7 @@
"charts": {
"loadingLabel": "Ładowanie wykresu…",
"loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.",
+ "adjustTargets": "Dostosuj wartości docelowe",
"weight": "Waga",
"bloodPressure": "Ciśnienie tętnicze",
"pulse": "Tętno",
@@ -1714,8 +1733,7 @@
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"show": "Pokaż",
"hide": "Ukryj",
- "manageSubPages": "Zarządzaj stronami szczegółów",
- "manageSubPagesHint": "Ukryte strony znikają również z górnej nawigacji.",
+ "manageInSettings": "Zarządzaj pigułkami i stronami szczegółów w ustawieniach",
"groupSleep": "Sen",
"groupMood": "Nastrój",
"groupEvents": "Zdarzenia",
@@ -1843,7 +1861,10 @@
"emptyTitle": "Jeszcze brak briefingu",
"emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.",
"emptyAction": "Wygeneruj briefing",
- "loadingLabel": "Przygotowanie briefingu"
+ "loadingLabel": "Przygotowanie briefingu",
+ "noProviderTitle": "Brak połączonego dostawcy AI",
+ "noProviderDescription": "Połącz dostawcę AI w ustawieniach, aby otrzymywać codzienne briefingi.",
+ "noProviderAction": "Otwórz ustawienia AI"
},
"trendsRow": {
"title": "Trendy",
@@ -1936,6 +1957,8 @@
"composerPlaceholder": "Zapytaj o swoje dane…",
"composerHint": "Enter wysyła, Shift+Enter przechodzi do nowej linii.",
"composerDisclaimer": "Odpowiedzi są generowane. Decyzje kliniczne należą do lekarza.",
+ "selfContextChipsLabel": "Coach chciałby wiedzieć:",
+ "selfContextChipDismiss": "Odrzuć pytanie",
"voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.",
"threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.",
"historyTitle": "Rozmowy",
@@ -2099,6 +2122,8 @@
"navSteps": "Kroki",
"navActiveEnergy": "Energia czynna",
"navAriaLabel": "Przejdź do sekcji",
+ "pillScrollLeft": "Przewiń karty w lewo",
+ "pillScrollRight": "Przewiń karty w prawo",
"tabStrip": {
"vitalsParent": {
"label": "Witalne",
@@ -2301,11 +2326,11 @@
"stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.",
"breathingDisturbancesTitle": "Zaburzenia oddychania we śnie",
"breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem.",
- "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo i dać ciału nadrobić zaległości. Traktuj go jako wskazówkę do rozłożenia sił w ciągu dnia, a nie jako wyrok.",
- "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie, biorąc pod uwagę, jak długo i jak efektywnie spałeś, jak regularne były Twoje pory oraz proporcje snu lekkiego, głębokiego i REM. Ma pokazać na pierwszy rzut oka, czy noc naprawdę Cię zregenerowała. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
- "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało i ile masz dziś zapasu, by przyjąć obciążenie — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje. To wskazówka, jak mocno się angażować, a nie sztywna granica.",
- "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, więc odzwierciedla to, co jest wymagające właśnie dla Ciebie. Wykorzystaj je, by równoważyć ciężkie dni lżejszymi i utrzymać trening na zrównoważonym poziomie.",
- "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojniejszy, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację. To opisowe przybliżenie pozwalające dostrzegać trendy, a nie pomiar kliniczny."
+ "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo.",
+ "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
+ "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.",
+ "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.",
+ "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację."
},
"stats": {
"label": "Statystyki podsumowujące",
@@ -3148,7 +3173,7 @@
},
"pillOrder": {
"title": "Sortuj pigułki nawigacyjne",
- "description": "Przeciągnij pigułki w kolejności, w jakiej mają się pojawiać u góry Analiz.",
+ "description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka. Ukryte strony szczegółów znikają również z górnej nawigacji.",
"dragHandle": "Zmień kolejność",
"saveSuccess": "Zapisano kolejność pigułek.",
"saveError": "Zapisanie kolejności pigułek nie powiodło się.",
@@ -3156,6 +3181,8 @@
"empty": "Brak dostępnych pigułek nawigacyjnych."
},
"customize": "Dostosuj Analizy",
+ "regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.",
+ "regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.",
"regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę."
},
"thresholds": {
@@ -3382,6 +3409,8 @@
"sources": {
"title": "Priorytet źródeł",
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
+ "integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.",
+ "integrationsHintLink": "Otwórz integracje",
"cardTitle": "Priorytet źródeł według metryki",
"help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.",
"resetDefaults": "Przywróć wartości domyślne",
@@ -3553,6 +3582,15 @@
"profile": "Profil",
"language": "Język",
"languageDescription": "Wybierz język wyświetlania aplikacji.",
+ "timeFormat": {
+ "title": "Format godziny",
+ "description": "Określa, czy godziny są wyświetlane w formacie 24-godzinnym czy 12-godzinnym (AM/PM).",
+ "auto": "Automatycznie (język)",
+ "h24": "24-godzinny",
+ "h12": "12-godzinny (AM/PM)",
+ "saved": "Zapisano format godziny.",
+ "saveError": "Nie udało się zapisać formatu godziny."
+ },
"username": "Nazwa użytkownika",
"height": "Wzrost (cm)",
"dateOfBirth": "Data urodzenia",
@@ -3642,8 +3680,8 @@
"codexDisconnected": "Połączenie z ChatGPT usunięte.",
"codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.",
"rawData": "Wysyłaj surowe dane",
- "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane surowe punkty z ostatnich 30 dni. Obejmuje to pomiary dla poszczególnych metryk (np. waga, ciśnienie, tętno) z kontekstem czasowym, aby dostawca mógł rzetelniej wykrywać wzorce, wartości odstające i korelacje. Imię, adres e-mail i bezpośrednie identyfikatory konta nie są przesyłane.",
- "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum). Bez pojedynczych punktów i bez dokładnego kontekstu czasowego. Lepiej chroni to prywatność, ale dostawca może być mniej precyzyjny w przypadku wzorców i krótkoterminowych wahań.",
+ "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane pojedyncze punkty z ostatnich 30 dni z kontekstem czasowym — nigdy imię, adres e-mail ani bezpośrednie identyfikatory konta.",
+ "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum) — bez pojedynczych punktów i bez dokładnego kontekstu czasowego.",
"rawDataWarning": "Tryb surowych danych włączony: dostawca otrzymuje dodatkowe zanonimizowane punkty z ostatnich 30 dni dla dokładniejszej analizy.",
"regenerateInsights": "Wygeneruj raporty ponownie",
"regenerateSuccess": "Raporty wygenerowane ponownie",
@@ -3775,6 +3813,30 @@
"savedHidden": "Coach ukryty.",
"savedShown": "Coach widoczny.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
+ },
+ "aboutMe": {
+ "title": "O mnie",
+ "description": "Osobisty kontekst, który Coach i codzienny briefing czytają obok Twoich danych.",
+ "placeholder": "Np. praca zmianowa, dieta wegetariańska, kontuzja kolana, trening do półmaratonu…",
+ "charCount": "{used} / {max} znaków",
+ "clear": "Wyczyść",
+ "save": "Zapisz",
+ "savedToast": "Notatka „O mnie” zapisana.",
+ "clearedToast": "Notatka „O mnie” usunięta.",
+ "saveError": "Nie udało się zapisać. Spróbuj ponownie.",
+ "loadError": "Nie udało się wczytać notatki.",
+ "hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać.",
+ "profileHint": "Wiek i płeć pochodzą z Twojego profilu i są dołączane automatycznie.",
+ "freeTextLabel": "O mnie (tekst dowolny)",
+ "conditionsLabel": "Choroby przewlekłe",
+ "conditionsPlaceholder": "Np. nadciśnienie, cukrzyca typu 2…",
+ "allergiesLabel": "Alergie i nietolerancje",
+ "allergiesPlaceholder": "Np. nietolerancja laktozy, alergia na penicylinę…",
+ "focusLabel": "Na co Coach ma zwracać uwagę?",
+ "focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…",
+ "completeness": "Wypełnione: {answered} z {total}",
+ "questionsTitle": "Coach ma pytania",
+ "questionsOpenCoach": "Odpowiedz w Coachu"
}
},
"dashboard": {
@@ -3804,6 +3866,21 @@
"withingsSyncResult": "{count} pomiarów zsynchronizowano",
"withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano",
"withingsSyncFailed": "Synchronizacja nie powiodła się",
+ "withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.",
+ "withingsOauthFailed": "Połączenie z Withings nie powiodło się",
+ "withingsOauthError": {
+ "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
+ "replay": "Ten link potwierdzający został już użyty. Rozpocznij połączenie ponownie.",
+ "state": "Odpowiedź Withings nie pasuje do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
+ "expired": "Żądanie połączenia wygasło. Rozpocznij połączenie ponownie i dokończ autoryzację w ciągu kilku minut.",
+ "cross_user": "Odpowiedź należy do żądania innego konta. Rozpocznij połączenie ponownie z własnego konta.",
+ "nocode": "Withings nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Withings.",
+ "nocreds": "Brak zapisanych danych dostępowych API Withings. Najpierw zapisz Client ID i Client Secret, potem połącz ponownie.",
+ "token": "Wymiana tokenów z Withings nie powiodła się. Sprawdź Client ID i Client Secret i spróbuj ponownie.",
+ "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
+ },
+ "integrationsSourcesHint": "Gdy kilka źródeł dostarcza tę samą metrykę, priorytet źródeł decyduje, która wartość się liczy.",
+ "integrationsSourcesHintLink": "Otwórz priorytet źródeł",
"withingsSynchronize": "Synchronizuj",
"withingsDisconnect": "Rozłącz",
"withingsDisconnectTitle": "Rozłączyć Withings?",
@@ -4240,6 +4317,55 @@
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Token zgłoszeń",
"bugReportTokenPlaceholder": "ghp_...",
+ "invites": {
+ "title": "Zaproszenia",
+ "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.",
+ "adminOnlyHint": "Tylko administratorzy mogą tworzyć i unieważniać zaproszenia.",
+ "create": "Utwórz zaproszenie",
+ "createTitle": "Utwórz zaproszenie",
+ "createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.",
+ "ttlLegend": "Ważne przez",
+ "createDays": "{days} dni",
+ "maxUsesLabel": "Maksymalna liczba użyć",
+ "maxUsesHint": "Ile rejestracji dopuszcza ten link (1–50).",
+ "createConfirm": "Utwórz zaproszenie",
+ "createdToast": "Zaproszenie utworzone.",
+ "createError": "Nie udało się utworzyć zaproszenia.",
+ "tokenTitle": "Link z zaproszeniem",
+ "tokenOnceHint": "Do skopiowania tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.",
+ "hashExplainer": "Zapisywany jest tylko hash linku — nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.",
+ "copyLink": "Kopiuj link",
+ "copiedToast": "Link skopiowany.",
+ "copyError": "Kopiowanie nie powiodło się.",
+ "qrAlt": "Kod QR linku z zaproszeniem",
+ "loadError": "Nie udało się wczytać zaproszeń.",
+ "emptyTitle": "Brak zaproszeń",
+ "emptyDescription": "Utwórz link z zaproszeniem, aby przyjąć kogoś mimo zamkniętej rejestracji.",
+ "colStatus": "Status",
+ "colCreated": "Utworzono",
+ "colExpires": "Wygasa",
+ "colUses": "Użycia",
+ "colRedeemedBy": "Wykorzystane przez",
+ "colActions": "Akcje",
+ "byCreator": "przez {by}",
+ "expiresInDaysOne": "za 1 dzień",
+ "expiresInDaysOther": "za {count} dni",
+ "statusActive": "Aktywne",
+ "statusExpired": "Wygasłe",
+ "statusExhausted": "Wyczerpane",
+ "statusRevoked": "Unieważnione",
+ "redeemerDeleted": "usunięte konto",
+ "redeemedByMore": "{username} + {count} więcej",
+ "redemptionsTitle": "Wykorzystania: {count}",
+ "revoke": "Unieważnij",
+ "revokeAria": "Unieważnij zaproszenie",
+ "revokeConfirmTitle": "Unieważnić to zaproszenie?",
+ "revokeConfirmBody": "Link z zaproszeniem natychmiast straci ważność. Konta już zarejestrowane przez ten link pozostaną, a historia wykorzystań będzie nadal widoczna.",
+ "revokeConfirmAction": "Unieważnij",
+ "cancel": "Anuluj",
+ "revokedToast": "Zaproszenie unieważnione.",
+ "revokeError": "Nie udało się unieważnić zaproszenia."
+ },
"userManagement": "Zarządzanie użytkownikami",
"userName": "Nazwa",
"userEmail": "E-mail",
@@ -4458,6 +4584,10 @@
"emptyDescription": "Brak użytkowników pasujących do bieżącego filtra.",
"emptyResetFilter": "Pokaż wszystkich użytkowników"
},
+ "invites": {
+ "title": "Zaproszenia",
+ "subtitle": "Tworzenie, śledzenie i unieważnianie linków z zaproszeniem."
+ },
"api-tokens": {
"title": "Tokeny API",
"subtitle": "Aktywne tokeny bearer wydane klientom natywnym i integracjom."
@@ -4903,6 +5033,16 @@
"hourAria": "Godzina przypomnienia o nastroju",
"hourSavedToast": "Godzina przypomnienia zaktualizowana."
},
+ "coachNudge": {
+ "title": "Proaktywny Coach",
+ "description": "Odzywa się najwyżej raz w tygodniu, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia.",
+ "toggleAria": "Przełącz proaktywne podpowiedzi Coacha",
+ "statusOn": "Aktywne — maks. 1 raz w tygodniu",
+ "statusOff": "Wyłączone",
+ "enabledToast": "Proaktywne podpowiedzi Coacha włączone.",
+ "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.",
+ "saveError": "Nie udało się zapisać. Spróbuj ponownie później."
+ },
"eventMedicationReminder": "Przypomnienie o leku",
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
"eventMeasurementAnomaly": "Nietypowy pomiar",
@@ -5089,6 +5229,20 @@
"dailyTitle": "Zapisz nastrój",
"dailyBody": "Jak się dzisiaj czujesz?"
},
+ "coachNudges": {
+ "complianceTitle": "Twój Coach ma na ten temat przemyślenie",
+ "complianceBody": "W tym tygodniu Twoje dawki były przyjmowane nieregularnie. Porozmawiaj chwilę z Coachem — może plan da się uprościć.",
+ "bpTitle": "Podwyższone ciśnienie w tym tygodniu",
+ "bpBody": "Twoje średnie ciśnienie w tym tygodniu przekroczyło cel. Coach pomoże Ci umieścić wyniki w kontekście.",
+ "scoreTitle": "Twój wskaźnik regeneracji spada",
+ "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.",
+ "selfContextTitle": "Pomóż Coachowi lepiej Cię poznać",
+ "selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.",
+ "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?",
+ "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?",
+ "questionAllergies": "Czy masz alergie lub nietolerancje?",
+ "questionAboutMe": "Chcesz opowiedzieć Coachowi coś o sobie — codzienność, cele, kontekst?"
+ },
"clinicianView": {
"title": "Udostępniona dokumentacja zdrowotna",
"period": "Okres raportu: {start} – {end}",
diff --git a/package.json b/package.json
index fd98f0e4c..5a94546d2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.15.19",
+ "version": "1.16.0",
"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",
@@ -59,8 +59,8 @@
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@simplewebauthn/browser": "^13.3.0",
- "@simplewebauthn/server": "^13.3.0",
- "@tanstack/react-query": "^5.100.10",
+ "@simplewebauthn/server": "^13.3.1",
+ "@tanstack/react-query": "^5.101.0",
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -75,10 +75,11 @@
"p-limit": "^7.3.0",
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
+ "qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
- "react-hook-form": "^7.76.1",
+ "react-hook-form": "^7.77.0",
"recharts": "^3.8.1",
"rrule": "^2.8.1",
"sax": "^1.6.0",
@@ -97,8 +98,9 @@
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.1",
- "@types/node": "^25",
+ "@types/node": "^25.9.2",
"@types/pg": "^8.20.0",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
@@ -106,7 +108,7 @@
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.6",
- "knip": "^6.14.2",
+ "knip": "^6.16.1",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"prettier": "^3.8.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3a4840041..7e4d11f33 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -22,7 +22,7 @@ importers:
version: 3.2.2(react@19.2.5)
'@hookform/resolvers':
specifier: ^5.4.0
- version: 5.4.0(react-hook-form@7.76.1(react@19.2.5))
+ version: 5.4.0(react-hook-form@7.77.0(react@19.2.5))
'@node-rs/argon2':
specifier: ^2.0.2
version: 2.0.2
@@ -39,11 +39,11 @@ importers:
specifier: ^13.3.0
version: 13.3.0
'@simplewebauthn/server':
- specifier: ^13.3.0
- version: 13.3.0
+ specifier: ^13.3.1
+ version: 13.3.1
'@tanstack/react-query':
- specifier: ^5.100.10
- version: 5.100.10(react@19.2.5)
+ specifier: ^5.101.0
+ version: 5.101.0(react@19.2.5)
'@types/sax':
specifier: ^1.2.7
version: 1.2.7
@@ -86,6 +86,9 @@ importers:
prisma:
specifier: ^7.8.0
version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -96,8 +99,8 @@ importers:
specifier: 19.2.5
version: 19.2.5(react@19.2.5)
react-hook-form:
- specifier: ^7.76.1
- version: 7.76.1(react@19.2.5)
+ specifier: ^7.77.0
+ version: 7.77.0(react@19.2.5)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1)
@@ -148,11 +151,14 @@ importers:
specifier: ^12.0.1
version: 12.0.1
'@types/node':
- specifier: ^25
- version: 25.9.1
+ specifier: ^25.9.2
+ version: 25.9.2
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
'@types/react':
specifier: ^19
version: 19.2.14
@@ -164,7 +170,7 @@ importers:
version: 3.6.4
'@vitejs/plugin-react':
specifier: ^5.1.4
- version: 5.1.4(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ version: 5.1.4(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@vitest/coverage-v8':
specifier: ^4.1.5
version: 4.1.5(vitest@4.1.5)
@@ -175,8 +181,8 @@ importers:
specifier: 16.2.6
version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3)
knip:
- specifier: ^6.14.2
- version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ specifier: ^6.16.1
+ version: 6.16.1
pdf-parse:
specifier: ^2.4.5
version: 2.4.5
@@ -191,7 +197,7 @@ importers:
version: 0.8.0(prettier@3.8.3)
shadcn:
specifier: ^4.9.0
- version: 4.9.0(@types/node@25.9.1)(typescript@6.0.3)
+ version: 4.9.0(@types/node@25.9.2)(typescript@6.0.3)
tailwindcss:
specifier: ^4
version: 4.3.0
@@ -206,7 +212,7 @@ importers:
version: 6.0.3
vitest:
specifier: ^4.1.5
- version: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ version: 4.1.5(@types/node@25.9.2)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
packages:
@@ -1387,241 +1393,236 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
- '@oxc-parser/binding-android-arm-eabi@0.130.0':
- resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==}
+ '@oxc-parser/binding-android-arm-eabi@0.133.0':
+ resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
- '@oxc-parser/binding-android-arm64@0.130.0':
- resolution: {integrity: sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==}
+ '@oxc-parser/binding-android-arm64@0.133.0':
+ resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@oxc-parser/binding-darwin-arm64@0.130.0':
- resolution: {integrity: sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==}
+ '@oxc-parser/binding-darwin-arm64@0.133.0':
+ resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@oxc-parser/binding-darwin-x64@0.130.0':
- resolution: {integrity: sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==}
+ '@oxc-parser/binding-darwin-x64@0.133.0':
+ resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@oxc-parser/binding-freebsd-x64@0.130.0':
- resolution: {integrity: sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==}
+ '@oxc-parser/binding-freebsd-x64@0.133.0':
+ resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
- resolution: {integrity: sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==}
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0':
+ resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
- resolution: {integrity: sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==}
+ '@oxc-parser/binding-linux-arm-musleabihf@0.133.0':
+ resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxc-parser/binding-linux-arm64-gnu@0.130.0':
- resolution: {integrity: sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==}
+ '@oxc-parser/binding-linux-arm64-gnu@0.133.0':
+ resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-arm64-musl@0.130.0':
- resolution: {integrity: sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==}
+ '@oxc-parser/binding-linux-arm64-musl@0.133.0':
+ resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
- resolution: {integrity: sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==}
+ '@oxc-parser/binding-linux-ppc64-gnu@0.133.0':
+ resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
- resolution: {integrity: sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==}
+ '@oxc-parser/binding-linux-riscv64-gnu@0.133.0':
+ resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-riscv64-musl@0.130.0':
- resolution: {integrity: sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==}
+ '@oxc-parser/binding-linux-riscv64-musl@0.133.0':
+ resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
- '@oxc-parser/binding-linux-s390x-gnu@0.130.0':
- resolution: {integrity: sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==}
+ '@oxc-parser/binding-linux-s390x-gnu@0.133.0':
+ resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-x64-gnu@0.130.0':
- resolution: {integrity: sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==}
+ '@oxc-parser/binding-linux-x64-gnu@0.133.0':
+ resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-x64-musl@0.130.0':
- resolution: {integrity: sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==}
+ '@oxc-parser/binding-linux-x64-musl@0.133.0':
+ resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
- '@oxc-parser/binding-openharmony-arm64@0.130.0':
- resolution: {integrity: sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==}
+ '@oxc-parser/binding-openharmony-arm64@0.133.0':
+ resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@oxc-parser/binding-wasm32-wasi@0.130.0':
- resolution: {integrity: sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==}
+ '@oxc-parser/binding-wasm32-wasi@0.133.0':
+ resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
- '@oxc-parser/binding-win32-arm64-msvc@0.130.0':
- resolution: {integrity: sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==}
+ '@oxc-parser/binding-win32-arm64-msvc@0.133.0':
+ resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@oxc-parser/binding-win32-ia32-msvc@0.130.0':
- resolution: {integrity: sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==}
+ '@oxc-parser/binding-win32-ia32-msvc@0.133.0':
+ resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
- '@oxc-parser/binding-win32-x64-msvc@0.130.0':
- resolution: {integrity: sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==}
+ '@oxc-parser/binding-win32-x64-msvc@0.133.0':
+ resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
- '@oxc-project/types@0.130.0':
- resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
+ '@oxc-project/types@0.133.0':
+ resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
- '@oxc-resolver/binding-android-arm-eabi@11.19.1':
- resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
+ '@oxc-resolver/binding-android-arm-eabi@11.20.0':
+ resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==}
cpu: [arm]
os: [android]
- '@oxc-resolver/binding-android-arm64@11.19.1':
- resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==}
+ '@oxc-resolver/binding-android-arm64@11.20.0':
+ resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==}
cpu: [arm64]
os: [android]
- '@oxc-resolver/binding-darwin-arm64@11.19.1':
- resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==}
+ '@oxc-resolver/binding-darwin-arm64@11.20.0':
+ resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==}
cpu: [arm64]
os: [darwin]
- '@oxc-resolver/binding-darwin-x64@11.19.1':
- resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==}
+ '@oxc-resolver/binding-darwin-x64@11.20.0':
+ resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==}
cpu: [x64]
os: [darwin]
- '@oxc-resolver/binding-freebsd-x64@11.19.1':
- resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==}
+ '@oxc-resolver/binding-freebsd-x64@11.20.0':
+ resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==}
cpu: [x64]
os: [freebsd]
- '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
- resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==}
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0':
+ resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==}
cpu: [arm]
os: [linux]
- '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
- resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==}
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0':
+ resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==}
cpu: [arm]
os: [linux]
- '@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
- resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==}
+ '@oxc-resolver/binding-linux-arm64-gnu@11.20.0':
+ resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@oxc-resolver/binding-linux-arm64-musl@11.19.1':
- resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==}
+ '@oxc-resolver/binding-linux-arm64-musl@11.20.0':
+ resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
- resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==}
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0':
+ resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
- resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==}
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0':
+ resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
- resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==}
+ '@oxc-resolver/binding-linux-riscv64-musl@11.20.0':
+ resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
- '@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
- resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==}
+ '@oxc-resolver/binding-linux-s390x-gnu@11.20.0':
+ resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@oxc-resolver/binding-linux-x64-gnu@11.19.1':
- resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==}
+ '@oxc-resolver/binding-linux-x64-gnu@11.20.0':
+ resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@oxc-resolver/binding-linux-x64-musl@11.19.1':
- resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==}
+ '@oxc-resolver/binding-linux-x64-musl@11.20.0':
+ resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@oxc-resolver/binding-openharmony-arm64@11.19.1':
- resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==}
+ '@oxc-resolver/binding-openharmony-arm64@11.20.0':
+ resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==}
cpu: [arm64]
os: [openharmony]
- '@oxc-resolver/binding-wasm32-wasi@11.19.1':
- resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==}
+ '@oxc-resolver/binding-wasm32-wasi@11.20.0':
+ resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
- '@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
- resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==}
+ '@oxc-resolver/binding-win32-arm64-msvc@11.20.0':
+ resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==}
cpu: [arm64]
os: [win32]
- '@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
- resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==}
- cpu: [ia32]
- os: [win32]
-
- '@oxc-resolver/binding-win32-x64-msvc@11.19.1':
- resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==}
+ '@oxc-resolver/binding-win32-x64-msvc@11.20.0':
+ resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==}
cpu: [x64]
os: [win32]
@@ -2622,8 +2623,8 @@ packages:
'@simplewebauthn/browser@13.3.0':
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
- '@simplewebauthn/server@13.3.0':
- resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==}
+ '@simplewebauthn/server@13.3.1':
+ resolution: {integrity: sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==}
engines: {node: '>=20.0.0'}
'@sindresorhus/merge-streams@4.0.0':
@@ -2767,11 +2768,11 @@ packages:
'@tailwindcss/postcss@4.3.0':
resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==}
- '@tanstack/query-core@5.100.10':
- resolution: {integrity: sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==}
+ '@tanstack/query-core@5.101.0':
+ resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
- '@tanstack/react-query@5.100.10':
- resolution: {integrity: sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==}
+ '@tanstack/react-query@5.101.0':
+ resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==}
peerDependencies:
react: ^18 || ^19
@@ -2847,8 +2848,8 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
- '@types/node@25.9.1':
- resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
+ '@types/node@25.9.2':
+ resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
@@ -2856,6 +2857,9 @@ packages:
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
@@ -3435,6 +3439,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
@@ -3486,6 +3494,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -3690,6 +3701,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@@ -3757,6 +3772,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
docker-compose@1.4.2:
resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==}
engines: {node: '>= 6.0.0'}
@@ -4166,6 +4184,10 @@ packages:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -4754,8 +4776,8 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
- knip@6.14.2:
- resolution: {integrity: sha512-Vg3JhIINjZew1I7qAFI4UHemW1mc4azP/BxJvsq9eGDfxpGO7oVCuD/bsWkog9TO/ZwwJeAeOMFZ1kd9jnY9+Q==}
+ knip@6.16.1:
+ resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -4851,6 +4873,10 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -5197,12 +5223,16 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
- oxc-parser@0.130.0:
- resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==}
+ oxc-parser@0.133.0:
+ resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==}
engines: {node: ^20.19.0 || >=22.12.0}
- oxc-resolver@11.19.1:
- resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
+ oxc-resolver@11.20.0:
+ resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==}
+
+ p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
@@ -5212,10 +5242,18 @@ packages:
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
engines: {node: '>=20'}
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -5358,6 +5396,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -5533,6 +5575,11 @@ packages:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
@@ -5572,8 +5619,8 @@ packages:
peerDependencies:
react: ^19.2.5
- react-hook-form@7.76.1:
- resolution: {integrity: sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==}
+ react-hook-form@7.77.0:
+ resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
@@ -5694,6 +5741,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -5804,6 +5854,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
@@ -6416,6 +6469,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which-typed-array@1.1.20:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
@@ -6439,6 +6495,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -6474,6 +6534,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6491,10 +6554,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -7377,10 +7448,10 @@ snapshots:
dependencies:
hono: 4.12.23
- '@hookform/resolvers@5.4.0(react-hook-form@7.76.1(react@19.2.5))':
+ '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.5))':
dependencies:
'@standard-schema/utils': 0.3.0
- react-hook-form: 7.76.1(react@19.2.5)
+ react-hook-form: 7.77.0(react@19.2.5)
'@humanfs/core@0.19.1': {}
@@ -7492,30 +7563,30 @@ snapshots:
'@inquirer/ansi@2.0.7': {}
- '@inquirer/confirm@6.1.1(@types/node@25.9.1)':
+ '@inquirer/confirm@6.1.1(@types/node@25.9.2)':
dependencies:
- '@inquirer/core': 11.2.1(@types/node@25.9.1)
- '@inquirer/type': 4.0.7(@types/node@25.9.1)
+ '@inquirer/core': 11.2.1(@types/node@25.9.2)
+ '@inquirer/type': 4.0.7(@types/node@25.9.2)
optionalDependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
- '@inquirer/core@11.2.1(@types/node@25.9.1)':
+ '@inquirer/core@11.2.1(@types/node@25.9.2)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/figures': 2.0.7
- '@inquirer/type': 4.0.7(@types/node@25.9.1)
+ '@inquirer/type': 4.0.7(@types/node@25.9.2)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.2
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@inquirer/figures@2.0.7': {}
- '@inquirer/type@4.0.7(@types/node@25.9.1)':
+ '@inquirer/type@4.0.7(@types/node@25.9.2)':
optionalDependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@isaacs/cliui@8.0.2':
dependencies:
@@ -7778,135 +7849,131 @@ snapshots:
'@open-draft/until@2.1.0': {}
- '@oxc-parser/binding-android-arm-eabi@0.130.0':
+ '@oxc-parser/binding-android-arm-eabi@0.133.0':
optional: true
- '@oxc-parser/binding-android-arm64@0.130.0':
+ '@oxc-parser/binding-android-arm64@0.133.0':
optional: true
- '@oxc-parser/binding-darwin-arm64@0.130.0':
+ '@oxc-parser/binding-darwin-arm64@0.133.0':
optional: true
- '@oxc-parser/binding-darwin-x64@0.130.0':
+ '@oxc-parser/binding-darwin-x64@0.133.0':
optional: true
- '@oxc-parser/binding-freebsd-x64@0.130.0':
+ '@oxc-parser/binding-freebsd-x64@0.133.0':
optional: true
- '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0':
optional: true
- '@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
+ '@oxc-parser/binding-linux-arm-musleabihf@0.133.0':
optional: true
- '@oxc-parser/binding-linux-arm64-gnu@0.130.0':
+ '@oxc-parser/binding-linux-arm64-gnu@0.133.0':
optional: true
- '@oxc-parser/binding-linux-arm64-musl@0.130.0':
+ '@oxc-parser/binding-linux-arm64-musl@0.133.0':
optional: true
- '@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
+ '@oxc-parser/binding-linux-ppc64-gnu@0.133.0':
optional: true
- '@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
+ '@oxc-parser/binding-linux-riscv64-gnu@0.133.0':
optional: true
- '@oxc-parser/binding-linux-riscv64-musl@0.130.0':
+ '@oxc-parser/binding-linux-riscv64-musl@0.133.0':
optional: true
- '@oxc-parser/binding-linux-s390x-gnu@0.130.0':
+ '@oxc-parser/binding-linux-s390x-gnu@0.133.0':
optional: true
- '@oxc-parser/binding-linux-x64-gnu@0.130.0':
+ '@oxc-parser/binding-linux-x64-gnu@0.133.0':
optional: true
- '@oxc-parser/binding-linux-x64-musl@0.130.0':
+ '@oxc-parser/binding-linux-x64-musl@0.133.0':
optional: true
- '@oxc-parser/binding-openharmony-arm64@0.130.0':
+ '@oxc-parser/binding-openharmony-arm64@0.133.0':
optional: true
- '@oxc-parser/binding-wasm32-wasi@0.130.0':
+ '@oxc-parser/binding-wasm32-wasi@0.133.0':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
- '@oxc-parser/binding-win32-arm64-msvc@0.130.0':
+ '@oxc-parser/binding-win32-arm64-msvc@0.133.0':
optional: true
- '@oxc-parser/binding-win32-ia32-msvc@0.130.0':
+ '@oxc-parser/binding-win32-ia32-msvc@0.133.0':
optional: true
- '@oxc-parser/binding-win32-x64-msvc@0.130.0':
+ '@oxc-parser/binding-win32-x64-msvc@0.133.0':
optional: true
- '@oxc-project/types@0.130.0': {}
+ '@oxc-project/types@0.133.0': {}
- '@oxc-resolver/binding-android-arm-eabi@11.19.1':
+ '@oxc-resolver/binding-android-arm-eabi@11.20.0':
optional: true
- '@oxc-resolver/binding-android-arm64@11.19.1':
+ '@oxc-resolver/binding-android-arm64@11.20.0':
optional: true
- '@oxc-resolver/binding-darwin-arm64@11.19.1':
+ '@oxc-resolver/binding-darwin-arm64@11.20.0':
optional: true
- '@oxc-resolver/binding-darwin-x64@11.19.1':
+ '@oxc-resolver/binding-darwin-x64@11.20.0':
optional: true
- '@oxc-resolver/binding-freebsd-x64@11.19.1':
+ '@oxc-resolver/binding-freebsd-x64@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
+ '@oxc-resolver/binding-linux-arm64-gnu@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-arm64-musl@11.19.1':
+ '@oxc-resolver/binding-linux-arm64-musl@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
+ '@oxc-resolver/binding-linux-riscv64-musl@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
+ '@oxc-resolver/binding-linux-s390x-gnu@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-x64-gnu@11.19.1':
+ '@oxc-resolver/binding-linux-x64-gnu@11.20.0':
optional: true
- '@oxc-resolver/binding-linux-x64-musl@11.19.1':
+ '@oxc-resolver/binding-linux-x64-musl@11.20.0':
optional: true
- '@oxc-resolver/binding-openharmony-arm64@11.19.1':
+ '@oxc-resolver/binding-openharmony-arm64@11.20.0':
optional: true
- '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ '@oxc-resolver/binding-wasm32-wasi@11.20.0':
dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
- transitivePeerDependencies:
- - '@emnapi/core'
- - '@emnapi/runtime'
optional: true
- '@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
+ '@oxc-resolver/binding-win32-arm64-msvc@11.20.0':
optional: true
- '@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
- optional: true
-
- '@oxc-resolver/binding-win32-x64-msvc@11.19.1':
+ '@oxc-resolver/binding-win32-x64-msvc@11.20.0':
optional: true
'@parse/node-apn@8.1.0':
@@ -8986,7 +9053,7 @@ snapshots:
'@simplewebauthn/browser@13.3.0': {}
- '@simplewebauthn/server@13.3.0':
+ '@simplewebauthn/server@13.3.1':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
@@ -9124,11 +9191,11 @@ snapshots:
postcss: 8.5.14
tailwindcss: 4.3.0
- '@tanstack/query-core@5.100.10': {}
+ '@tanstack/query-core@5.101.0': {}
- '@tanstack/react-query@5.100.10(react@19.2.5)':
+ '@tanstack/react-query@5.101.0(react@19.2.5)':
dependencies:
- '@tanstack/query-core': 5.100.10
+ '@tanstack/query-core': 5.101.0
react: 19.2.5
'@testcontainers/postgresql@12.0.1':
@@ -9205,13 +9272,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@types/ssh2': 1.15.5
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@types/ssh2': 1.15.5
'@types/estree@1.0.8': {}
@@ -9224,7 +9291,7 @@ snapshots:
dependencies:
undici-types: 5.26.5
- '@types/node@25.9.1':
+ '@types/node@25.9.2':
dependencies:
undici-types: 7.24.6
@@ -9232,10 +9299,14 @@ snapshots:
'@types/pg@8.20.0':
dependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
pg-protocol: 1.13.0
pg-types: 2.2.0
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 25.9.2
+
'@types/raf@3.4.3':
optional: true
@@ -9249,19 +9320,19 @@ snapshots:
'@types/sax@1.2.7':
dependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@types/set-cookie-parser@2.4.10':
dependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@types/ssh2-streams@0.1.13':
dependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@types/ssh2@0.5.52':
dependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -9279,7 +9350,7 @@ snapshots:
'@types/web-push@3.6.4':
dependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3)':
dependencies:
@@ -9431,7 +9502,7 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
- '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
+ '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -9439,7 +9510,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
- vite: 7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ vite: 7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
@@ -9455,7 +9526,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ vitest: 4.1.5(@types/node@25.9.2)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@vitest/expect@4.1.5':
dependencies:
@@ -9466,14 +9537,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.5(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
+ '@vitest/mocker@4.1.5(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- msw: 2.14.6(@types/node@25.9.1)(typescript@6.0.3)
- vite: 7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ msw: 2.14.6(@types/node@25.9.2)(typescript@6.0.3)
+ vite: 7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
'@vitest/pretty-format@4.1.5':
dependencies:
@@ -9866,6 +9937,8 @@ snapshots:
callsites@3.1.0: {}
+ camelcase@5.3.1: {}
+
caniuse-lite@1.0.30001792: {}
caniuse-lite@1.0.30001793: {}
@@ -9915,6 +9988,12 @@ snapshots:
client-only@0.0.1: {}
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -10086,6 +10165,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decimal.js-light@2.5.1: {}
dedent@1.7.2: {}
@@ -10131,6 +10212,8 @@ snapshots:
diff@8.0.4: {}
+ dijkstrajs@1.0.3: {}
+
docker-compose@1.4.2:
dependencies:
yaml: 2.9.0
@@ -10770,6 +10853,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -11314,25 +11402,21 @@ snapshots:
kleur@4.1.5: {}
- knip@6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
+ knip@6.16.1:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
formatly: 0.3.0
get-tsconfig: 4.14.0
jiti: 2.7.0
- minimist: 1.2.8
- oxc-parser: 0.130.0
- oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ oxc-parser: 0.133.0
+ oxc-resolver: 11.20.0
picomatch: 4.0.4
smol-toml: 1.6.1
strip-json-comments: 5.0.3
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
unbash: 3.0.0
yaml: 2.9.0
zod: 4.4.3
- transitivePeerDependencies:
- - '@emnapi/core'
- - '@emnapi/runtime'
language-subtag-registry@0.3.23: {}
@@ -11400,6 +11484,10 @@ snapshots:
lines-and-columns@1.2.4: {}
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -11524,9 +11612,9 @@ snapshots:
ms@2.1.3: {}
- msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3):
+ msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3):
dependencies:
- '@inquirer/confirm': 6.1.1(@types/node@25.9.1)
+ '@inquirer/confirm': 6.1.1(@types/node@25.9.2)
'@mswjs/interceptors': 0.41.9
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
@@ -11744,56 +11832,56 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
- oxc-parser@0.130.0:
+ oxc-parser@0.133.0:
dependencies:
- '@oxc-project/types': 0.130.0
+ '@oxc-project/types': 0.133.0
optionalDependencies:
- '@oxc-parser/binding-android-arm-eabi': 0.130.0
- '@oxc-parser/binding-android-arm64': 0.130.0
- '@oxc-parser/binding-darwin-arm64': 0.130.0
- '@oxc-parser/binding-darwin-x64': 0.130.0
- '@oxc-parser/binding-freebsd-x64': 0.130.0
- '@oxc-parser/binding-linux-arm-gnueabihf': 0.130.0
- '@oxc-parser/binding-linux-arm-musleabihf': 0.130.0
- '@oxc-parser/binding-linux-arm64-gnu': 0.130.0
- '@oxc-parser/binding-linux-arm64-musl': 0.130.0
- '@oxc-parser/binding-linux-ppc64-gnu': 0.130.0
- '@oxc-parser/binding-linux-riscv64-gnu': 0.130.0
- '@oxc-parser/binding-linux-riscv64-musl': 0.130.0
- '@oxc-parser/binding-linux-s390x-gnu': 0.130.0
- '@oxc-parser/binding-linux-x64-gnu': 0.130.0
- '@oxc-parser/binding-linux-x64-musl': 0.130.0
- '@oxc-parser/binding-openharmony-arm64': 0.130.0
- '@oxc-parser/binding-wasm32-wasi': 0.130.0
- '@oxc-parser/binding-win32-arm64-msvc': 0.130.0
- '@oxc-parser/binding-win32-ia32-msvc': 0.130.0
- '@oxc-parser/binding-win32-x64-msvc': 0.130.0
+ '@oxc-parser/binding-android-arm-eabi': 0.133.0
+ '@oxc-parser/binding-android-arm64': 0.133.0
+ '@oxc-parser/binding-darwin-arm64': 0.133.0
+ '@oxc-parser/binding-darwin-x64': 0.133.0
+ '@oxc-parser/binding-freebsd-x64': 0.133.0
+ '@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0
+ '@oxc-parser/binding-linux-arm-musleabihf': 0.133.0
+ '@oxc-parser/binding-linux-arm64-gnu': 0.133.0
+ '@oxc-parser/binding-linux-arm64-musl': 0.133.0
+ '@oxc-parser/binding-linux-ppc64-gnu': 0.133.0
+ '@oxc-parser/binding-linux-riscv64-gnu': 0.133.0
+ '@oxc-parser/binding-linux-riscv64-musl': 0.133.0
+ '@oxc-parser/binding-linux-s390x-gnu': 0.133.0
+ '@oxc-parser/binding-linux-x64-gnu': 0.133.0
+ '@oxc-parser/binding-linux-x64-musl': 0.133.0
+ '@oxc-parser/binding-openharmony-arm64': 0.133.0
+ '@oxc-parser/binding-wasm32-wasi': 0.133.0
+ '@oxc-parser/binding-win32-arm64-msvc': 0.133.0
+ '@oxc-parser/binding-win32-ia32-msvc': 0.133.0
+ '@oxc-parser/binding-win32-x64-msvc': 0.133.0
- oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
+ oxc-resolver@11.20.0:
optionalDependencies:
- '@oxc-resolver/binding-android-arm-eabi': 11.19.1
- '@oxc-resolver/binding-android-arm64': 11.19.1
- '@oxc-resolver/binding-darwin-arm64': 11.19.1
- '@oxc-resolver/binding-darwin-x64': 11.19.1
- '@oxc-resolver/binding-freebsd-x64': 11.19.1
- '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1
- '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1
- '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1
- '@oxc-resolver/binding-linux-arm64-musl': 11.19.1
- '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1
- '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1
- '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1
- '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1
- '@oxc-resolver/binding-linux-x64-gnu': 11.19.1
- '@oxc-resolver/binding-linux-x64-musl': 11.19.1
- '@oxc-resolver/binding-openharmony-arm64': 11.19.1
- '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
- '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
- '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
- '@oxc-resolver/binding-win32-x64-msvc': 11.19.1
- transitivePeerDependencies:
- - '@emnapi/core'
- - '@emnapi/runtime'
+ '@oxc-resolver/binding-android-arm-eabi': 11.20.0
+ '@oxc-resolver/binding-android-arm64': 11.20.0
+ '@oxc-resolver/binding-darwin-arm64': 11.20.0
+ '@oxc-resolver/binding-darwin-x64': 11.20.0
+ '@oxc-resolver/binding-freebsd-x64': 11.20.0
+ '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0
+ '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0
+ '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0
+ '@oxc-resolver/binding-linux-arm64-musl': 11.20.0
+ '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0
+ '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0
+ '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0
+ '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0
+ '@oxc-resolver/binding-linux-x64-gnu': 11.20.0
+ '@oxc-resolver/binding-linux-x64-musl': 11.20.0
+ '@oxc-resolver/binding-openharmony-arm64': 11.20.0
+ '@oxc-resolver/binding-wasm32-wasi': 11.20.0
+ '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0
+ '@oxc-resolver/binding-win32-x64-msvc': 11.20.0
+
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
p-limit@3.1.0:
dependencies:
@@ -11803,10 +11891,16 @@ snapshots:
dependencies:
yocto-queue: 1.2.2
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
+ p-try@2.2.0: {}
+
package-json-from-dist@1.0.1: {}
pako@2.1.0: {}
@@ -11930,6 +12024,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
+ pngjs@5.0.0: {}
+
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.1:
@@ -12040,7 +12136,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
long: 5.3.2
proxy-addr@2.0.7:
@@ -12063,6 +12159,12 @@ snapshots:
pvutils@1.1.5: {}
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -12156,7 +12258,7 @@ snapshots:
react: 19.2.5
scheduler: 0.27.0
- react-hook-form@7.76.1(react@19.2.5):
+ react-hook-form@7.77.0(react@19.2.5):
dependencies:
react: 19.2.5
@@ -12297,6 +12399,8 @@ snapshots:
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
reselect@5.1.1: {}
resolve-from@4.0.0: {}
@@ -12444,6 +12548,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-blocking@2.0.0: {}
+
set-cookie-parser@3.1.0: {}
set-function-length@1.2.2:
@@ -12470,7 +12576,7 @@ snapshots:
setprototypeof@1.2.0: {}
- shadcn@4.9.0(@types/node@25.9.1)(typescript@6.0.3):
+ shadcn@4.9.0(@types/node@25.9.2)(typescript@6.0.3):
dependencies:
'@babel/core': 7.29.7
'@babel/parser': 7.29.7
@@ -12491,7 +12597,7 @@ snapshots:
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
- msw: 2.14.6(@types/node@25.9.1)(typescript@6.0.3)
+ msw: 2.14.6(@types/node@25.9.2)(typescript@6.0.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
@@ -13107,7 +13213,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
- vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4):
+ vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@@ -13116,16 +13222,16 @@ snapshots:
rollup: 4.60.4
tinyglobby: 0.2.17
optionalDependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
fsevents: 2.3.3
jiti: 2.7.0
lightningcss: 1.32.0
yaml: 2.8.4
- vitest@4.1.5(@types/node@25.9.1)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
+ vitest@4.1.5(@types/node@25.9.2)(@vitest/coverage-v8@4.1.5)(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.5
- '@vitest/mocker': 4.1.5(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
+ '@vitest/mocker': 4.1.5(msw@2.14.6(@types/node@25.9.2)(typescript@6.0.3))(vite@7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -13142,10 +13248,10 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
- vite: 7.3.1(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
+ vite: 7.3.1(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
why-is-node-running: 2.3.0
optionalDependencies:
- '@types/node': 25.9.1
+ '@types/node': 25.9.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
transitivePeerDependencies:
- msw
@@ -13214,6 +13320,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
+ which-module@2.0.1: {}
+
which-typed-array@1.1.20:
dependencies:
available-typed-arrays: 1.0.7
@@ -13239,6 +13347,12 @@ snapshots:
word-wrap@1.2.5: {}
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -13264,6 +13378,8 @@ snapshots:
xtend@4.0.2: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -13272,8 +13388,27 @@ snapshots:
yaml@2.9.0: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
diff --git a/prisma/migrations/0138_v11520_time_format/migration.sql b/prisma/migrations/0138_v11520_time_format/migration.sql
new file mode 100644
index 000000000..27506091e
--- /dev/null
+++ b/prisma/migrations/0138_v11520_time_format/migration.sql
@@ -0,0 +1,14 @@
+-- v1.15.20 — per-user hour-cycle display preference.
+--
+-- The UI locale alone decided whether times rendered as AM/PM or 24-hour,
+-- which left e.g. an en-locale user no way to read 24-hour times. The new
+-- column carries an explicit preference: AUTO follows the locale convention,
+-- H12 forces AM/PM, H24 forces 24-hour. Display-time only — stored instants
+-- stay UTC.
+--
+-- Additive + non-destructive: a new NOT NULL column with a default, no
+-- backfill needed. Existing rows read AUTO.
+CREATE TYPE "time_format_preference" AS ENUM ('AUTO', 'H12', 'H24');
+
+ALTER TABLE "users"
+ ADD COLUMN "time_format" "time_format_preference" NOT NULL DEFAULT 'AUTO';
diff --git a/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql b/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql
new file mode 100644
index 000000000..1baec338b
--- /dev/null
+++ b/prisma/migrations/0139_v11520_intake_attribution_source/migration.sql
@@ -0,0 +1,16 @@
+-- v1.15.20 — provenance of an intake row's slot binding.
+--
+-- An off-window take can now be deliberately pinned onto a scheduled slot
+-- ("diesem Slot zuordnen"). The column records HOW the row's `scheduled_for`
+-- binding came to be: AUTO = window-band attribution (or a skip / pending
+-- mint anchored on its slot), USER_PIN = a deliberate user pin. The read
+-- ledger binds USER_PIN rows by their stored anchor (like skips) instead of
+-- re-running takenAt-band attribution, so a pinned take outside the late
+-- tail stays on its slot — rendered taken-late, never on-time-washed.
+--
+-- Additive + non-destructive: a new NOT NULL column with a default, no
+-- backfill needed. Existing rows read AUTO.
+CREATE TYPE "intake_attribution_source" AS ENUM ('AUTO', 'USER_PIN');
+
+ALTER TABLE "medication_intake_events"
+ ADD COLUMN "attribution_source" "intake_attribution_source" NOT NULL DEFAULT 'AUTO';
diff --git a/prisma/migrations/0140_v11520_user_health_profile/migration.sql b/prisma/migrations/0140_v11520_user_health_profile/migration.sql
new file mode 100644
index 000000000..20191dfd7
--- /dev/null
+++ b/prisma/migrations/0140_v11520_user_health_profile/migration.sql
@@ -0,0 +1,22 @@
+-- v1.15.20 — user-authored "about me" self-description for the AI surfaces.
+--
+-- 1:1 with users, lazily created on first save. The free text is encrypted
+-- at rest (AES-256-GCM through the shared Bytes codec, the CoachFact
+-- precedent) — `about_me_encrypted` carries the `"."`
+-- ciphertext as UTF-8 bytes and is NULL when the user cleared (or never
+-- wrote) the text. Additive + non-destructive.
+CREATE TABLE "user_health_profiles" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "about_me_encrypted" BYTEA,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "user_health_profiles_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "user_health_profiles_user_id_key" ON "user_health_profiles"("user_id");
+
+ALTER TABLE "user_health_profiles"
+ ADD CONSTRAINT "user_health_profiles_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/0141_v11520_invite_tokens/migration.sql b/prisma/migrations/0141_v11520_invite_tokens/migration.sql
new file mode 100644
index 000000000..5b6947b6c
--- /dev/null
+++ b/prisma/migrations/0141_v11520_invite_tokens/migration.sql
@@ -0,0 +1,32 @@
+-- v1.15.20 — registration invite tokens.
+--
+-- Admits a signup while open registration is disabled. Only the
+-- HMAC-SHA256 hash of the raw token is stored (the ApiToken scheme);
+-- `expires_at` is mandatory (route-capped at 30 days), `uses`/`max_uses`
+-- carry the consumption budget, `used_by` tracks the most recent consumer
+-- and survives account deletion as NULL. Additive + non-destructive.
+CREATE TABLE "invite_tokens" (
+ "id" TEXT NOT NULL,
+ "token_hash" TEXT NOT NULL,
+ "created_by" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expires_at" TIMESTAMP(3) NOT NULL,
+ "used_at" TIMESTAMP(3),
+ "used_by" TEXT,
+ "max_uses" INTEGER NOT NULL DEFAULT 1,
+ "uses" INTEGER NOT NULL DEFAULT 0,
+
+ CONSTRAINT "invite_tokens_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "invite_tokens_token_hash_key" ON "invite_tokens"("token_hash");
+
+CREATE INDEX "invite_tokens_expires_at_idx" ON "invite_tokens"("expires_at");
+
+ALTER TABLE "invite_tokens"
+ ADD CONSTRAINT "invite_tokens_created_by_fkey"
+ FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "invite_tokens"
+ ADD CONSTRAINT "invite_tokens_used_by_fkey"
+ FOREIGN KEY ("used_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/0142_v1160_invite_redemptions/migration.sql b/prisma/migrations/0142_v1160_invite_redemptions/migration.sql
new file mode 100644
index 000000000..40902e67c
--- /dev/null
+++ b/prisma/migrations/0142_v1160_invite_redemptions/migration.sql
@@ -0,0 +1,44 @@
+-- v1.16.0 — invite redemption ledger + soft revocation.
+--
+-- `invite_tokens.used_by` keeps only the LAST consumer (multi-use
+-- invites overwrite it); `invite_redemptions` carries one row per
+-- admitted signup so the admin table can show the full history with
+-- timestamps. `user_id` survives account deletion as NULL — the row
+-- still documents that a use was consumed.
+--
+-- `revoked_at` turns revocation into a soft state: a revoked invite
+-- keeps its history visible instead of vanishing, and the consume path
+-- refuses it like an expired one. Additive + non-destructive.
+ALTER TABLE "invite_tokens" ADD COLUMN "revoked_at" TIMESTAMP(3);
+
+CREATE TABLE "invite_redemptions" (
+ "id" TEXT NOT NULL,
+ "invite_id" TEXT NOT NULL,
+ "user_id" TEXT,
+ "redeemed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "invite_redemptions_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "invite_redemptions_invite_id_idx" ON "invite_redemptions"("invite_id");
+
+ALTER TABLE "invite_redemptions"
+ ADD CONSTRAINT "invite_redemptions_invite_id_fkey"
+ FOREIGN KEY ("invite_id") REFERENCES "invite_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "invite_redemptions"
+ ADD CONSTRAINT "invite_redemptions_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- Backfill: every invite that already recorded a consumer becomes one
+-- redemption row, stamped with the invite's `used_at`. Invites whose
+-- consumer account was deleted (`used_by` NULL but `uses` > 0) cannot
+-- be reconstructed and stay ledger-less — the use counter still counts.
+INSERT INTO "invite_redemptions" ("id", "invite_id", "user_id", "redeemed_at")
+SELECT
+ 'invred_' || md5(random()::text || clock_timestamp()::text),
+ "id",
+ "used_by",
+ COALESCE("used_at", "created_at")
+FROM "invite_tokens"
+WHERE "used_by" IS NOT NULL;
diff --git a/prisma/migrations/0143_v1160_self_context_fields/migration.sql b/prisma/migrations/0143_v1160_self_context_fields/migration.sql
new file mode 100644
index 000000000..6bd6b2ff3
--- /dev/null
+++ b/prisma/migrations/0143_v1160_self_context_fields/migration.sql
@@ -0,0 +1,17 @@
+-- v1.16.0 — structured self-context fields on the health profile.
+--
+-- Three short structured companions to the free-text "about me"
+-- (chronic conditions, allergies/intolerances, what the Coach should
+-- watch), each encrypted at rest through the shared Bytes codec and
+-- capped at 500 chars BEFORE encryption. Age/gender deliberately do
+-- NOT live here — they come from the users table and are merged into
+-- the prompt block at assembly time.
+--
+-- `pending_questions_encrypted` carries up to 3 clarifying questions
+-- the server derives after a save (encrypted JSON string[]); the Coach
+-- composer renders them as tappable suggestion chips. Additive +
+-- non-destructive.
+ALTER TABLE "user_health_profiles" ADD COLUMN "conditions_encrypted" BYTEA;
+ALTER TABLE "user_health_profiles" ADD COLUMN "allergies_encrypted" BYTEA;
+ALTER TABLE "user_health_profiles" ADD COLUMN "coach_focus_encrypted" BYTEA;
+ALTER TABLE "user_health_profiles" ADD COLUMN "pending_questions_encrypted" BYTEA;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4ced5f92e..9cbe6696b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -132,6 +132,10 @@ model User {
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric"
+ // Hour-cycle display preference. AUTO follows the locale convention
+ // (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour.
+ // Display-time only — canonical storage stays UTC instants.
+ timeFormat TimeFormatPreference @default(AUTO) @map("time_format")
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
@@ -507,6 +511,18 @@ model User {
// seeded catalogue).
cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms")
+ // v1.15.20 — user-authored "about me" context for the AI surfaces
+ // (1:1, encrypted free text). See `UserHealthProfile`.
+ healthProfile UserHealthProfile?
+ // v1.15.20 — registration invite tokens (closed-registration flow).
+ // `InviteCreator` = admin who minted the invite; `InviteConsumer` =
+ // the account that registered through it (last consumer wins on a
+ // multi-use invite).
+ invitesCreated InviteToken[] @relation("InviteCreator")
+ invitesUsed InviteToken[] @relation("InviteConsumer")
+ // v1.16.0 — per-signup invite redemption ledger (multi-use history).
+ inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
+
@@map("users")
}
@@ -716,6 +732,16 @@ enum GlucoseContext {
@@map("glucose_context")
}
+// Hour-cycle display preference for rendered times. AUTO defers to the
+// active locale's convention; H12/H24 pin the cycle regardless of locale.
+enum TimeFormatPreference {
+ AUTO
+ H12
+ H24
+
+ @@map("time_format_preference")
+}
+
enum MeasurementSource {
MANUAL
WITHINGS
@@ -1414,6 +1440,19 @@ enum IntakeSource {
@@map("intake_source")
}
+/// v1.15.20 — provenance of an intake row's slot binding. `USER_PIN` marks
+/// an attribution the user fixed by hand (widened in v1.16.0): either a
+/// deliberate "diesem Slot zuordnen" pin onto a named slot, or its released
+/// ("Zuordnung lösen") counterpart that stays a standalone ad-hoc take
+/// (`scheduledFor === takenAt`). The nightly slot dedup never folds a
+/// USER_PIN row into a snap cluster, so neither decision is reverted.
+enum IntakeAttributionSource {
+ AUTO
+ USER_PIN
+
+ @@map("intake_attribution_source")
+}
+
enum ReminderPhase {
GREEN
YELLOW
@@ -1465,6 +1504,18 @@ model MedicationIntakeEvent {
/// legacy row defaults to false (a user skip or a live pending row,
/// neither of which is an auto-miss).
autoMissed Boolean @default(false) @map("auto_missed")
+ /// v1.15.20 — how this row's `scheduledFor` slot binding came to be.
+ /// `AUTO` (default) = the write path attributed the take by window-band
+ /// membership (or the row is a skip / pending mint anchored on its slot).
+ /// `USER_PIN` = the user fixed the attribution by hand (v1.16.0): a
+ /// deliberate pin onto a named slot ("diesem Slot zuordnen") OR the
+ /// released ("Zuordnung lösen") row that stays deliberately ad-hoc
+ /// (`scheduledFor === takenAt`). The read ledger binds slot-anchored
+ /// USER_PIN rows by their `scheduledFor` anchor (like skips) instead of
+ /// re-running takenAt-band attribution — visibly marked, counted late,
+ /// never on-time-washed — and surfaces released rows as ad-hoc; the
+ /// nightly dedup leaves both standalone.
+ attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source")
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
@@ -1786,6 +1837,58 @@ model ApiToken {
@@map("api_tokens")
}
+// v1.15.20 — registration invite tokens. When the operator has disabled
+// open registration (`AppSettings.registrationEnabled = false`), a valid,
+// unexpired invite token still admits a signup. The raw `hlv_` token
+// is shown exactly once at mint time; only its HMAC-SHA256 hash (keyed by
+// `API_TOKEN_HMAC_KEY`, the same scheme as `ApiToken.tokenHash`) is
+// stored. `expiresAt` is mandatory and capped at 30 days by the admin
+// route. `uses`/`maxUses` make multi-use invites possible; the register
+// path consumes a use with an atomic guarded increment so a concurrent
+// signup can never overshoot `maxUses`. `usedBy` tracks the most recent
+// consumer (informational; SetNull on account deletion so the invite
+// audit row outlives the account).
+model InviteToken {
+ id String @id @default(cuid())
+ tokenHash String @unique @map("token_hash")
+ createdBy String @map("created_by")
+ createdAt DateTime @default(now()) @map("created_at")
+ expiresAt DateTime @map("expires_at")
+ usedAt DateTime? @map("used_at")
+ usedBy String? @map("used_by")
+ maxUses Int @default(1) @map("max_uses")
+ uses Int @default(0)
+ // v1.16.0 — soft revocation. A revoked invite keeps its redemption
+ // history visible in the admin table instead of vanishing; the
+ // consume path refuses it like an expired one.
+ revokedAt DateTime? @map("revoked_at")
+
+ creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade)
+ consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull)
+ redemptions InviteRedemption[]
+
+ @@index([expiresAt])
+ @@map("invite_tokens")
+}
+
+// v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy`
+// keeps only the LAST consumer (it overwrites on multi-use invites);
+// this ledger carries the full history so the admin table can show
+// every redemption with its timestamp. `userId` survives account
+// deletion as NULL — the row still documents that a use was consumed.
+model InviteRedemption {
+ id String @id @default(cuid())
+ inviteId String @map("invite_id")
+ userId String? @map("user_id")
+ redeemedAt DateTime @default(now()) @map("redeemed_at")
+
+ invite InviteToken @relation(fields: [inviteId], references: [id], onDelete: Cascade)
+ user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull)
+
+ @@index([inviteId])
+ @@map("invite_redemptions")
+}
+
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
model RefreshToken {
@@ -2993,6 +3096,41 @@ model CoachFact {
@@map("coach_facts")
}
+// v1.15.20 — user-authored "about me" self-description the AI surfaces
+// (Coach system prompt + daily-briefing user prompt) inject as a clearly
+// delimited, user-provided context block. 1:1 with User and lazily
+// created on first save so existing accounts pay nothing. The free text
+// is encrypted at rest (AES-256-GCM via the shared Bytes codec, exactly
+// like `CoachFact.factEncrypted`) — it is unstructured health prose and
+// must never land in the table as plaintext. A cleared profile keeps the
+// row with a NULL payload so `updatedAt` documents the deletion instant.
+model UserHealthProfile {
+ id String @id @default(cuid())
+ userId String @unique @map("user_id")
+ // Free text, hard-capped at 4 000 chars by the route's Zod schema
+ // BEFORE encryption. NULL = cleared / never written.
+ aboutMeEncrypted Bytes? @map("about_me_encrypted")
+ // v1.16.0 — structured self-context companions to the free text.
+ // Short answers (each capped at 500 chars BEFORE encryption).
+ // Age/gender deliberately do NOT live here — they come from the User
+ // profile and are merged into the prompt block at assembly time so
+ // the data is never duplicated.
+ conditionsEncrypted Bytes? @map("conditions_encrypted")
+ allergiesEncrypted Bytes? @map("allergies_encrypted")
+ coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
+ // v1.16.0 — up to 3 clarifying questions the server derives after a
+ // save (AI-generated when a provider + budget allow, deterministic
+ // hints otherwise). Encrypted JSON string[]; the Coach composer
+ // renders them as tappable suggestion chips. NULL = none pending.
+ pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@map("user_health_profiles")
+}
+
// Per-user, per-day token-spend ledger for the AI Coach. The dispatcher
// reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before
// hitting any provider, so a runaway client cannot drain the operator's
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 000000000..b71aacf83
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/logo-maskable-512.png b/public/logo-maskable-512.png
new file mode 100644
index 000000000..f192bd598
Binary files /dev/null and b/public/logo-maskable-512.png differ
diff --git a/public/manifest.json b/public/manifest.json
index 8c3882279..d351c6fbb 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -13,12 +13,20 @@
{
"src": "/logo-192.png",
"sizes": "192x192",
- "type": "image/png"
+ "type": "image/png",
+ "purpose": "any"
},
{
"src": "/logo-512.png",
"sizes": "512x512",
- "type": "image/png"
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/logo-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
}
]
}
diff --git a/public/sw.js b/public/sw.js
index bbd0ae200..a726a174c 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -50,16 +50,24 @@ self.addEventListener("install", (event) => {
// ── Activate: clean up old caches ────────────────────────────────────────────
self.addEventListener("activate", (event) => {
event.waitUntil(
- caches
- .keys()
- .then((keys) =>
- Promise.all(
- keys
- .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
- .map((k) => caches.delete(k)),
+ Promise.all([
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
+ .map((k) => caches.delete(k)),
+ ),
),
- )
- .then(() => self.clients.claim()),
+ // Navigation preload lets the browser start the navigation request
+ // in parallel with service-worker startup; `networkFirst` consumes
+ // it via `event.preloadResponse`, shaving SW boot latency off every
+ // page navigation. Guarded — older engines lack the API.
+ self.registration.navigationPreload
+ ? self.registration.navigationPreload.enable()
+ : Promise.resolve(),
+ ]).then(() => self.clients.claim()),
);
});
@@ -86,9 +94,10 @@ self.addEventListener("fetch", (event) => {
return;
}
- // HTML pages — network-first with cache fallback
+ // HTML pages — network-first with cache fallback. The whole event is
+ // passed so `networkFirst` can consume the navigation-preload response.
if (request.headers.get("accept")?.includes("text/html")) {
- event.respondWith(networkFirst(request, PAGE_CACHE));
+ event.respondWith(networkFirst(event, PAGE_CACHE));
return;
}
});
@@ -110,11 +119,21 @@ async function cacheFirst(request, cacheName) {
}
/**
- * Network-first: try network, fall back to cache.
+ * Network-first: try network (preferring the navigation-preload response
+ * when the browser already started it), fall back to cache.
+ *
+ * The offline fallback only serves a shell cached under the CURRENT
+ * `CACHE_VERSION` cache names. The previous global `caches.match()` could
+ * resolve from a stale pre-update cache in the activation gap and serve a
+ * shell whose `/_next/static/*` chunk graph no longer exists
+ * (`ChunkLoadError` on the first lazy navigation).
*/
-async function networkFirst(request, cacheName) {
+async function networkFirst(event, cacheName) {
+ const { request } = event;
try {
- const response = await fetch(request);
+ const response =
+ (event.preloadResponse ? await event.preloadResponse : null) ||
+ (await fetch(request));
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
@@ -122,8 +141,15 @@ async function networkFirst(request, cacheName) {
}
return response;
} catch {
- const cached = await caches.match(request);
- if (cached) return cached;
+ // Version-scoped fallback: the page cache first, then the precached
+ // app shell — both names embed CACHE_VERSION, so a stale shell from a
+ // previous release can never be served here.
+ const pageCache = await caches.open(cacheName);
+ const cachedPage = await pageCache.match(request);
+ if (cachedPage) return cachedPage;
+ const staticCache = await caches.open(STATIC_CACHE);
+ const precached = await staticCache.match(request);
+ if (precached) return precached;
// Offline fallback. F-7 (mobile security audit, 2026-05-16): the
// previous fallback rendered hard-coded German body copy, which is
@@ -143,14 +169,16 @@ async function networkFirst(request, cacheName) {
}
/**
- * Trim cache to maxEntries by removing oldest entries.
+ * Trim cache to maxEntries by removing oldest entries. `keys()` returns
+ * insertion order, so one read + a single pass over the excess prefix
+ * replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads).
*/
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
- let keys = await cache.keys();
- while (keys.length > maxEntries) {
- await cache.delete(keys[0]);
- keys = await cache.keys();
+ const keys = await cache.keys();
+ const excess = keys.length - maxEntries;
+ for (let i = 0; i < excess; i++) {
+ await cache.delete(keys[i]);
}
}
diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts
index 47efeadb7..f244c10ea 100644
--- a/scripts/repair-intake-anomalies.ts
+++ b/scripts/repair-intake-anomalies.ts
@@ -7,8 +7,8 @@
* (cross-source duplicates the pre-v1.15.19 write path could mint).
* Repair: keep one winner per slot (H1 precedence, see `pickWinner`
* below) and soft-delete the losers — `deleted_at = now()`,
- * `sync_version` incremented, `updated_at` bumped via `@updatedAt` —
- * exactly the tombstone shape the per-event DELETE route and the
+ * `sync_version` incremented, `updated_at` bumped — exactly the
+ * tombstone shape the per-event DELETE route and the
* `intake-slot-dedup` worker write, so iOS delta-sync drops them.
*
* 2. IMPLAUSIBLE TAKEN_AT — live rows whose `taken_at` lands more than
@@ -19,33 +19,65 @@
* into soft-deleting them.
*
* After any mutation the affected `(user, medication, day)` compliance
- * rollups are recomputed through the shared rollup helpers — no SQL is
- * duplicated here. The rollup aggregates DISTINCT slots since v1.15.19,
- * so a recompute alone already heals the counts; the tombstones
- * additionally clear the phantom rows from the history view.
+ * rollups are recomputed with the SAME slot-level DISTINCT aggregation the
+ * shared helper runs — see `recomputeComplianceDay` below, a verbatim
+ * SQL twin of `recomputeMedicationComplianceForDay` in
+ * `src/lib/rollups/medication-compliance-rollups.ts`. The rollup
+ * aggregates DISTINCT slots since v1.15.19, so a recompute alone already
+ * heals the counts; the tombstones additionally clear the phantom rows
+ * from the history view.
*
* Default is a DRY-RUN that only reports. Idempotent: a second `--fix`
* run finds zero groups and changes nothing.
*
+ * SELF-CONTAINED by design (v1.16.0)
+ * ----------------------------------
+ * The production standalone image ships neither the project
+ * `node_modules` tree nor the `@/` path alias, so the previous
+ * `dotenv/config` + `@/lib/db` import chain died with "Cannot find
+ * module" inside the container. This script therefore depends on
+ * exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
+ * the environment (no dotenv). Inside the production image `pg` resolves
+ * via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
+ * `pg@8.x` there); in a repo checkout it resolves from the project
+ * `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
+ * standalone image strips it) and pins `pg` for any environment where
+ * neither resolution path exists.
+ *
* Usage
* -----
- * pnpm dlx tsx scripts/repair-intake-anomalies.ts # dry-run
- * pnpm dlx tsx scripts/repair-intake-anomalies.ts --user # scoped dry-run
- * pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix
- * pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
+ * # dry-run (DATABASE_URL must point at the target database)
+ * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
*
- * `DATABASE_URL` must point at the target database.
+ * # scoped dry-run
+ * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user
+ *
+ * # apply
+ * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
+ * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
*/
-import "dotenv/config";
-import { Prisma } from "@/generated/prisma/client";
-import { prisma } from "@/lib/db";
-import {
- dayKeyForScheduledFor,
- recomputeMedicationComplianceForDay,
-} from "@/lib/rollups/medication-compliance-rollups";
+import { Client, types } from "pg";
const TAG = "[repair-intake-anomalies]";
+// Prisma stores `DateTime` columns as `timestamp(3)` WITHOUT time zone,
+// carrying UTC wall-clock values. node-postgres would parse those as
+// LOCAL time by default; this parser pins them back to UTC so every
+// Date this script handles is the true instant. (OID 1114 = timestamp
+// without time zone.)
+types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
+
+/**
+ * Serialise a Date for a `timestamp without time zone` comparison /
+ * equality parameter. The ISO string's `Z` suffix is ignored by the
+ * `::timestamp` cast, so the bound value is the UTC wall-clock — the
+ * exact representation the column stores. Millisecond-precise, so
+ * slot-equality (`scheduled_for = $n::timestamp`) round-trips exactly.
+ */
+function utcParam(date: Date): string {
+ return date.toISOString();
+}
+
interface CliOptions {
fix: boolean;
tombstoneImplausible: boolean;
@@ -81,15 +113,29 @@ interface IntakeRow {
skipped: boolean;
source: string;
createdAt: Date;
+ /**
+ * v1.16.0 — slot-binding provenance; USER_PIN = the user fixed the
+ * attribution by hand (pin onto a slot OR released as deliberately
+ * ad-hoc with `scheduledFor === takenAt`).
+ */
+ attributionSource: string;
}
/**
- * Rank a row for the slot-winner pick: taken (2) > skipped (1) >
- * pending (0). `takenAt` set and `skipped` together still counts as
- * taken — the recorded dose dominates.
+ * Rank a row for the slot-winner pick: pinned take (3) > taken (2) >
+ * skipped (1) > pending (0). `takenAt` set and `skipped` together still
+ * counts as taken — the recorded dose dominates. The USER_PIN rung
+ * (v1.16.0) mirrors the dedup worker's rank
+ * (`src/lib/medications/intake-slot-dedup.ts`). The worker additionally
+ * excludes USER_PIN rows from its SNAP clusters (the snap could move a
+ * user-fixed anchor); this script needs no such exclusion — its groups
+ * are exact-instant, so a USER_PIN row in a group already sits ON its
+ * fixed anchor and the rank keeps it as the dose of record there.
*/
function rowRank(row: IntakeRow): number {
- if (row.takenAt !== null) return 2;
+ if (row.takenAt !== null) {
+ return row.attributionSource === "USER_PIN" ? 3 : 2;
+ }
if (row.skipped) return 1;
return 0;
}
@@ -125,18 +171,178 @@ function pickWinner(rows: IntakeRow[]): IntakeRow {
})[0];
}
-/** `userId -> timezone` cache for the rollup day-key derivation. */
-const tzCache = new Map();
+// ─────────────────────────────────────────────────────────────────────
+// Timezone / day-key helpers — self-contained twins of the app helpers
+// the previous revision imported. Reference implementations:
+// - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts`
+// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after
+// boundary: `src/lib/rollups/medication-compliance-rollups.ts`
+// ─────────────────────────────────────────────────────────────────────
-async function userTimezone(userId: string): Promise {
- if (!tzCache.has(userId)) {
- const user = await prisma.user.findUnique({
- where: { id: userId },
- select: { timezone: true },
- });
- tzCache.set(userId, user?.timezone ?? null);
+const DEFAULT_TIMEZONE = "Europe/Berlin";
+
+function isValidTimezone(tz: string): boolean {
+ try {
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
+ return true;
+ } catch {
+ return false;
}
- return tzCache.get(userId) ?? null;
+}
+
+function safeTimezone(tz: string | null | undefined): string {
+ if (tz && isValidTimezone(tz)) return tz;
+ return DEFAULT_TIMEZONE;
+}
+
+/** `YYYY-MM-DD` of the instant in the user's tz (en-CA = ISO order). */
+function dayKeyForScheduledFor(
+ scheduledFor: Date,
+ tz: string | null | undefined,
+): string {
+ return new Intl.DateTimeFormat("en-CA", {
+ timeZone: safeTimezone(tz),
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).format(scheduledFor);
+}
+
+/** UTC offset (minutes) of `tz` at the given instant; honours DST. */
+function tzOffsetMinutes(date: Date, tz: string): number {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: tz,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ }).formatToParts(date);
+ const get = (type: Intl.DateTimeFormatPartTypes): string =>
+ parts.find((p) => p.type === type)?.value ?? "0";
+ let hour = Number(get("hour"));
+ if (hour === 24) hour = 0;
+ const asIfUtc = Date.UTC(
+ Number(get("year")),
+ Number(get("month")) - 1,
+ Number(get("day")),
+ hour,
+ Number(get("minute")),
+ Number(get("second")),
+ );
+ return Math.round((asIfUtc - date.getTime()) / 60000);
+}
+
+/** UTC instant of local-midnight on `dayKey` in `tz` (2-pass for DST). */
+function startOfDayUtcInTz(dayKey: string, tz: string): Date {
+ const [yearStr, monthStr, dayStr] = dayKey.split("-");
+ const year = Number(yearStr);
+ const month = Number(monthStr);
+ const day = Number(dayStr);
+ let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
+ for (let i = 0; i < 2; i++) {
+ const offsetMin = tzOffsetMinutes(guess, tz);
+ guess = new Date(
+ Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000,
+ );
+ }
+ return guess;
+}
+
+/**
+ * `[start, windowEnd)` UTC window of the local calendar day — the same
+ * DST-safe boundary derivation `recomputeMedicationComplianceForDay`
+ * runs (23 / 25-hour days fold correctly because the end anchors on
+ * the NEXT day's local midnight, not `start + 24 h`).
+ */
+function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } {
+ const start = startOfDayUtcInTz(dayKey, tz);
+ const fallbackEnd = new Date(start.getTime() + 86_400_000);
+ const [y, m, d] = dayKey.split("-").map(Number);
+ const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000);
+ const nextDayKey = dayKeyForScheduledFor(probeNext, tz);
+ const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz);
+ return {
+ start,
+ end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd,
+ };
+}
+
+/**
+ * Recompute one `(userId, medicationId, dayKey)` compliance-rollup row.
+ *
+ * VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in
+ * `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in
+ * sync when the rollup contract changes. v1.15.19 semantics: the inner
+ * CTE folds rows sharing one `scheduled_for` instant into one DISTINCT
+ * slot with taken-beats-skipped priority; the outer aggregate counts
+ * slots, not rows, so a duplicated slot can never inflate `scheduled`.
+ * The upsert re-aggregates inside the statement (atomic against
+ * concurrent app writers); the trailing DELETE clears the row when the
+ * day no longer holds any live event.
+ */
+async function recomputeComplianceDay(
+ client: Client,
+ userId: string,
+ medicationId: string,
+ dayKey: string,
+ tz: string | null | undefined,
+): Promise {
+ const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz));
+ await client.query(
+ `
+ WITH slot AS (
+ SELECT
+ BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken,
+ BOOL_OR("skipped") AS slot_skipped
+ FROM "medication_intake_events"
+ WHERE "user_id" = $1
+ AND "medication_id" = $2
+ AND "deleted_at" IS NULL
+ AND "scheduled_for" >= $4::timestamp
+ AND "scheduled_for" < $5::timestamp
+ GROUP BY "scheduled_for"
+ ),
+ aggregate AS (
+ SELECT
+ COUNT(*)::int AS scheduled,
+ COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken,
+ COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped
+ FROM slot
+ )
+ INSERT INTO "medication_compliance_rollups"
+ ("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at")
+ SELECT $1, $2, $3, aggregate.scheduled, aggregate.taken, aggregate.skipped, NOW()
+ FROM aggregate
+ WHERE aggregate.scheduled > 0
+ ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE
+ SET "scheduled" = EXCLUDED."scheduled",
+ "taken" = EXCLUDED."taken",
+ "skipped" = EXCLUDED."skipped",
+ "computed_at" = EXCLUDED."computed_at"
+ `,
+ [userId, medicationId, dayKey, utcParam(start), utcParam(end)],
+ );
+ await client.query(
+ `
+ DELETE FROM "medication_compliance_rollups"
+ WHERE "user_id" = $1
+ AND "medication_id" = $2
+ AND "day" = $3
+ AND NOT EXISTS (
+ SELECT 1
+ FROM "medication_intake_events"
+ WHERE "user_id" = $1
+ AND "medication_id" = $2
+ AND "deleted_at" IS NULL
+ AND "scheduled_for" >= $4::timestamp
+ AND "scheduled_for" < $5::timestamp
+ )
+ `,
+ [userId, medicationId, dayKey, utcParam(start), utcParam(end)],
+ );
}
interface Summary {
@@ -149,7 +355,10 @@ interface Summary {
async function main(): Promise {
if (!process.env.DATABASE_URL) {
- console.error(`${TAG} DATABASE_URL must be set`);
+ console.error(
+ `${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
+ `the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
+ );
process.exit(1);
}
@@ -167,6 +376,9 @@ async function main(): Promise {
(opts.userId ? ` user=${opts.userId}` : " (all users)"),
);
+ const client = new Client({ connectionString: process.env.DATABASE_URL });
+ await client.connect();
+
const summary: Summary = {
duplicateGroups: 0,
rowsTombstoned: 0,
@@ -175,95 +387,132 @@ async function main(): Promise {
rollupsRecomputed: 0,
};
- const userFilter = opts.userId
- ? Prisma.sql`AND e."user_id" = ${opts.userId}`
- : Prisma.empty;
+ // Optional per-user scope. The fragment is a fixed string with a
+ // positional placeholder — never user-input spliced.
+ const userFilter = opts.userId ? `AND e."user_id" = $1` : "";
+ const userParams = opts.userId ? [opts.userId] : [];
+
+ /** `userId -> timezone` cache for the rollup day-key derivation. */
+ const tzCache = new Map();
+ async function userTimezone(userId: string): Promise {
+ if (!tzCache.has(userId)) {
+ const res = await client.query<{ timezone: string | null }>(
+ `SELECT "timezone" FROM "users" WHERE "id" = $1`,
+ [userId],
+ );
+ tzCache.set(userId, res.rows[0]?.timezone ?? null);
+ }
+ return tzCache.get(userId) ?? null;
+ }
// `(userId, medicationId, dayKey)` tuples whose compliance rollup needs
// a recompute after the tombstones. Deduped so a busy day folds once.
const daysToRecompute = new Set();
- // ───────────────────────────────────────────────────────────────────
- // 1. Slot duplicates — exact (user, medication, scheduled_for) tuples
- // carrying more than one live row.
- // ───────────────────────────────────────────────────────────────────
- const groups = await prisma.$queryRaw<
- Array<{
+ try {
+ // ─────────────────────────────────────────────────────────────────
+ // 1. Slot duplicates — exact (user, medication, scheduled_for)
+ // tuples carrying more than one live row.
+ // ─────────────────────────────────────────────────────────────────
+ const groups = await client.query<{
user_id: string;
medication_id: string;
scheduled_for: Date;
row_count: number;
- }>
- >`
- SELECT e."user_id", e."medication_id", e."scheduled_for",
- COUNT(*)::int AS row_count
- FROM "medication_intake_events" e
- WHERE e."deleted_at" IS NULL
- ${userFilter}
- GROUP BY e."user_id", e."medication_id", e."scheduled_for"
- HAVING COUNT(*) > 1
- ORDER BY e."user_id", e."medication_id", e."scheduled_for"
- `;
-
- summary.duplicateGroups = groups.length;
- console.log(`\n${TAG} 1) duplicate slot groups found: ${groups.length}`);
-
- for (const group of groups) {
- const rows = (await prisma.medicationIntakeEvent.findMany({
- where: {
- userId: group.user_id,
- medicationId: group.medication_id,
- scheduledFor: group.scheduled_for,
- deletedAt: null,
- },
- select: {
- id: true,
- takenAt: true,
- skipped: true,
- source: true,
- createdAt: true,
- },
- orderBy: [{ createdAt: "asc" }, { id: "asc" }],
- })) as IntakeRow[];
- if (rows.length < 2) continue; // collapsed since the scan — nothing to do.
-
- const winner = pickWinner(rows);
- const losers = rows.filter((r) => r.id !== winner.id);
-
- console.log(
- ` - user=${group.user_id} medication=${group.medication_id} ` +
- `slot=${group.scheduled_for.toISOString()} rows=${rows.length} ` +
- `keep=${winner.id} (${winner.source}, ` +
- `${winner.takenAt ? `taken ${winner.takenAt.toISOString()}` : winner.skipped ? "skipped" : "pending"}) ` +
- `tombstone=[${losers.map((r) => `${r.id} (${r.source})`).join(", ")}]`,
+ }>(
+ `
+ SELECT e."user_id", e."medication_id", e."scheduled_for",
+ COUNT(*)::int AS row_count
+ FROM "medication_intake_events" e
+ WHERE e."deleted_at" IS NULL
+ ${userFilter}
+ GROUP BY e."user_id", e."medication_id", e."scheduled_for"
+ HAVING COUNT(*) > 1
+ ORDER BY e."user_id", e."medication_id", e."scheduled_for"
+ `,
+ userParams,
);
- if (opts.fix) {
- // Same tombstone the per-event DELETE route and the dedup worker
- // write: soft-delete + syncVersion bump (the sync feed echoes a
- // monotonic value so iOS drops the row); `updatedAt` bumps via the
- // schema's `@updatedAt`. The `deletedAt: null` guard keeps the
- // write idempotent against a concurrent collapse.
- const res = await prisma.medicationIntakeEvent.updateMany({
- where: { id: { in: losers.map((r) => r.id) }, deletedAt: null },
- data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
- });
- summary.rowsTombstoned += res.count;
+ summary.duplicateGroups = groups.rows.length;
+ console.log(
+ `\n${TAG} 1) duplicate slot groups found: ${groups.rows.length}`,
+ );
- const tz = await userTimezone(group.user_id);
- const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
- daysToRecompute.add(
- `${group.user_id}|${group.medication_id}|${dayKey}`,
+ for (const group of groups.rows) {
+ const rowsRes = await client.query<{
+ id: string;
+ taken_at: Date | null;
+ skipped: boolean;
+ source: string;
+ created_at: Date;
+ attribution_source: string;
+ }>(
+ `
+ SELECT "id", "taken_at", "skipped", "source"::text AS source, "created_at",
+ "attribution_source"::text AS attribution_source
+ FROM "medication_intake_events"
+ WHERE "user_id" = $1
+ AND "medication_id" = $2
+ AND "scheduled_for" = $3::timestamp
+ AND "deleted_at" IS NULL
+ ORDER BY "created_at" ASC, "id" ASC
+ `,
+ [group.user_id, group.medication_id, utcParam(group.scheduled_for)],
);
- }
- }
+ const rows: IntakeRow[] = rowsRes.rows.map((r) => ({
+ id: r.id,
+ takenAt: r.taken_at,
+ skipped: r.skipped,
+ source: r.source,
+ createdAt: r.created_at,
+ attributionSource: r.attribution_source,
+ }));
+ if (rows.length < 2) continue; // collapsed since the scan — nothing to do.
- // ───────────────────────────────────────────────────────────────────
- // 2. Implausible taken_at — report only; tombstone needs explicit
- // operator opt-in.
- // ───────────────────────────────────────────────────────────────────
- const implausible = await prisma.$queryRaw<
- Array<{
+ const winner = pickWinner(rows);
+ const losers = rows.filter((r) => r.id !== winner.id);
+
+ console.log(
+ ` - user=${group.user_id} medication=${group.medication_id} ` +
+ `slot=${group.scheduled_for.toISOString()} rows=${rows.length} ` +
+ `keep=${winner.id} (${winner.source}, ` +
+ `${winner.takenAt ? `taken ${winner.takenAt.toISOString()}` : winner.skipped ? "skipped" : "pending"}) ` +
+ `tombstone=[${losers.map((r) => `${r.id} (${r.source})`).join(", ")}]`,
+ );
+
+ if (opts.fix) {
+ // Same tombstone the per-event DELETE route and the dedup worker
+ // write: soft-delete + syncVersion bump (the sync feed echoes a
+ // monotonic value so iOS drops the row). Prisma's `@updatedAt`
+ // is client-side, so raw SQL bumps `updated_at` explicitly —
+ // the offline-sync keyset feed orders on it. The
+ // `deleted_at IS NULL` guard keeps the write idempotent against
+ // a concurrent collapse.
+ const res = await client.query(
+ `
+ UPDATE "medication_intake_events"
+ SET "deleted_at" = NOW(),
+ "sync_version" = "sync_version" + 1,
+ "updated_at" = NOW()
+ WHERE "id" = ANY($1) AND "deleted_at" IS NULL
+ `,
+ [losers.map((r) => r.id)],
+ );
+ summary.rowsTombstoned += res.rowCount ?? 0;
+
+ const tz = await userTimezone(group.user_id);
+ const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
+ daysToRecompute.add(
+ `${group.user_id}|${group.medication_id}|${dayKey}`,
+ );
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────
+ // 2. Implausible taken_at — report only; tombstone needs explicit
+ // operator opt-in.
+ // ─────────────────────────────────────────────────────────────────
+ const implausibleRes = await client.query<{
id: string;
user_id: string;
medication_id: string;
@@ -273,91 +522,104 @@ async function main(): Promise {
source: string;
created_at: Date;
updated_at: Date;
- }>
- >`
- SELECT e."id", e."user_id", e."medication_id",
- m."name" AS medication_name,
- e."scheduled_for", e."taken_at", e."source"::text AS source,
- e."created_at", e."updated_at"
- FROM "medication_intake_events" e
- JOIN "medications" m ON m."id" = e."medication_id"
- WHERE e."deleted_at" IS NULL
- AND e."taken_at" IS NOT NULL
- AND (e."taken_at" < e."scheduled_for" - interval '7 days'
- OR e."taken_at" > now() + interval '1 day')
- ${userFilter}
- ORDER BY e."user_id", e."taken_at"
- `;
-
- summary.implausibleRows = implausible.length;
- console.log(`\n${TAG} 2) implausible taken_at rows found: ${implausible.length}`);
- if (implausible.length > 0) {
- console.table(
- implausible.map((r) => ({
- id: r.id,
- medication: r.medication_name,
- scheduledFor: r.scheduled_for.toISOString(),
- takenAt: r.taken_at.toISOString(),
- source: r.source,
- createdAt: r.created_at.toISOString(),
- updatedAt: r.updated_at.toISOString(),
- })),
+ }>(
+ `
+ SELECT e."id", e."user_id", e."medication_id",
+ m."name" AS medication_name,
+ e."scheduled_for", e."taken_at", e."source"::text AS source,
+ e."created_at", e."updated_at"
+ FROM "medication_intake_events" e
+ JOIN "medications" m ON m."id" = e."medication_id"
+ WHERE e."deleted_at" IS NULL
+ AND e."taken_at" IS NOT NULL
+ AND (e."taken_at" < e."scheduled_for" - interval '7 days'
+ OR e."taken_at" > now() + interval '1 day')
+ ${userFilter}
+ ORDER BY e."user_id", e."taken_at"
+ `,
+ userParams,
);
- if (!(opts.fix && opts.tombstoneImplausible)) {
- console.log(
- `${TAG} these rows are NOT changed automatically — the recorded ` +
- `intent is unknowable. Correct or delete them in the medication ` +
- `history tab, or re-run with --fix --tombstone-implausible to ` +
- `soft-delete them.`,
+ const implausible = implausibleRes.rows;
+
+ summary.implausibleRows = implausible.length;
+ console.log(
+ `\n${TAG} 2) implausible taken_at rows found: ${implausible.length}`,
+ );
+ if (implausible.length > 0) {
+ console.table(
+ implausible.map((r) => ({
+ id: r.id,
+ medication: r.medication_name,
+ scheduledFor: r.scheduled_for.toISOString(),
+ takenAt: r.taken_at.toISOString(),
+ source: r.source,
+ createdAt: r.created_at.toISOString(),
+ updatedAt: r.updated_at.toISOString(),
+ })),
);
- }
- }
-
- if (opts.fix && opts.tombstoneImplausible && implausible.length > 0) {
- const res = await prisma.medicationIntakeEvent.updateMany({
- where: {
- id: { in: implausible.map((r) => r.id) },
- deletedAt: null,
- },
- data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
- });
- summary.implausibleTombstoned = res.count;
- console.log(
- `${TAG} tombstoned ${res.count} implausible row(s) (operator opt-in)`,
- );
-
- for (const row of implausible) {
- const tz = await userTimezone(row.user_id);
- const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
- daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
- }
- }
-
- // ───────────────────────────────────────────────────────────────────
- // 3. Recompute the affected compliance rollups so scheduled/taken
- // counts self-correct. Shared helper — no SQL duplicated here.
- // ───────────────────────────────────────────────────────────────────
- if (opts.fix && daysToRecompute.size > 0) {
- console.log(
- `\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
- );
- for (const key of daysToRecompute) {
- const [userId, medicationId, dayKey] = key.split("|");
- try {
- await recomputeMedicationComplianceForDay(
- userId,
- medicationId,
- dayKey,
- await userTimezone(userId),
- );
- summary.rollupsRecomputed += 1;
- } catch (err) {
- console.error(
- `${TAG} rollup recompute failed for ${key}: ` +
- `${err instanceof Error ? err.message : String(err)}`,
+ if (!(opts.fix && opts.tombstoneImplausible)) {
+ console.log(
+ `${TAG} these rows are NOT changed automatically — the recorded ` +
+ `intent is unknowable. Correct or delete them in the medication ` +
+ `history tab, or re-run with --fix --tombstone-implausible to ` +
+ `soft-delete them.`,
);
}
}
+
+ if (opts.fix && opts.tombstoneImplausible && implausible.length > 0) {
+ const res = await client.query(
+ `
+ UPDATE "medication_intake_events"
+ SET "deleted_at" = NOW(),
+ "sync_version" = "sync_version" + 1,
+ "updated_at" = NOW()
+ WHERE "id" = ANY($1) AND "deleted_at" IS NULL
+ `,
+ [implausible.map((r) => r.id)],
+ );
+ summary.implausibleTombstoned = res.rowCount ?? 0;
+ console.log(
+ `${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`,
+ );
+
+ for (const row of implausible) {
+ const tz = await userTimezone(row.user_id);
+ const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
+ daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────
+ // 3. Recompute the affected compliance rollups so scheduled/taken
+ // counts self-correct. Same DISTINCT-slot SQL the shared helper
+ // runs (see `recomputeComplianceDay` above).
+ // ─────────────────────────────────────────────────────────────────
+ if (opts.fix && daysToRecompute.size > 0) {
+ console.log(
+ `\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
+ );
+ for (const key of daysToRecompute) {
+ const [userId, medicationId, dayKey] = key.split("|");
+ try {
+ await recomputeComplianceDay(
+ client,
+ userId,
+ medicationId,
+ dayKey,
+ await userTimezone(userId),
+ );
+ summary.rollupsRecomputed += 1;
+ } catch (err) {
+ console.error(
+ `${TAG} rollup recompute failed for ${key}: ` +
+ `${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+ }
+ } finally {
+ await client.end();
}
// ───────────────────────────────────────────────────────────────────
@@ -371,11 +633,7 @@ async function main(): Promise {
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
}
-main()
- .catch((err) => {
- console.error(`${TAG} fatal error:`, err);
- process.exit(1);
- })
- .finally(async () => {
- await prisma.$disconnect();
- });
+main().catch((err) => {
+ console.error(`${TAG} fatal error:`, err);
+ process.exit(1);
+});
diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts
new file mode 100644
index 000000000..66ba97460
--- /dev/null
+++ b/src/__tests__/service-worker.test.ts
@@ -0,0 +1,264 @@
+/**
+ * v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox.
+ *
+ * `public/sw.js` is plain script (no module system), so the test boots it
+ * inside a vm context with a fake CacheStorage and dispatches the captured
+ * event listeners. Three behaviours are pinned:
+ *
+ * 1. the offline HTML fallback only serves a shell cached under the
+ * CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would
+ * reference a chunk graph that no longer exists);
+ * 2. `activate` enables navigation preload and `networkFirst` consumes
+ * `event.preloadResponse` when the browser supplies it;
+ * 3. `trimCache` reads the key list once and deletes the excess prefix
+ * in a single pass (the previous loop re-fetched all keys per delete).
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import vm from "node:vm";
+
+const SW_SOURCE = readFileSync(
+ resolve(__dirname, "../../public/sw.js"),
+ "utf8",
+);
+
+const ORIGIN = "https://app.example";
+// `importScripts` throws in the sandbox, so the literal fallback version
+// is active and the cache names are deterministic.
+const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43";
+const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43";
+
+class FakeCache {
+ map = new Map();
+ keysCalls = 0;
+
+ private keyOf(request: RequestInfo | URL): string {
+ if (typeof request === "string") return new URL(request, ORIGIN).href;
+ if (request instanceof URL) return request.href;
+ return request.url;
+ }
+
+ async match(request: RequestInfo | URL): Promise {
+ return this.map.get(this.keyOf(request));
+ }
+
+ async put(request: RequestInfo | URL, response: Response): Promise {
+ this.map.set(this.keyOf(request), response);
+ }
+
+ async addAll(urls: string[]): Promise {
+ for (const url of urls) {
+ this.map.set(this.keyOf(url), new Response(`precached:${url}`));
+ }
+ }
+
+ async keys(): Promise {
+ this.keysCalls += 1;
+ return [...this.map.keys()].map((url) => new Request(url));
+ }
+
+ async delete(request: RequestInfo | URL): Promise {
+ return this.map.delete(this.keyOf(request));
+ }
+}
+
+class FakeCacheStorage {
+ stores = new Map();
+
+ async open(name: string): Promise {
+ let store = this.stores.get(name);
+ if (!store) {
+ store = new FakeCache();
+ this.stores.set(name, store);
+ }
+ return store;
+ }
+
+ async keys(): Promise {
+ return [...this.stores.keys()];
+ }
+
+ async delete(name: string): Promise {
+ return this.stores.delete(name);
+ }
+
+ async match(request: RequestInfo | URL): Promise {
+ for (const store of this.stores.values()) {
+ const hit = await store.match(request);
+ if (hit) return hit;
+ }
+ return undefined;
+ }
+}
+
+interface SwHarness {
+ listeners: Map void>;
+ cacheStorage: FakeCacheStorage;
+ navPreload: { enabled: boolean; enable: () => Promise };
+ context: Record;
+}
+
+function bootServiceWorker(): SwHarness {
+ const listeners = new Map void>();
+ const cacheStorage = new FakeCacheStorage();
+ const navPreload = {
+ enabled: false,
+ enable: async () => {
+ navPreload.enabled = true;
+ },
+ };
+ const selfObj = {
+ addEventListener: (type: string, fn: (event: unknown) => void) => {
+ listeners.set(type, fn);
+ },
+ skipWaiting: async () => {},
+ clients: { claim: async () => {} },
+ registration: { navigationPreload: navPreload },
+ location: { origin: ORIGIN },
+ };
+ const context: Record = {
+ self: selfObj,
+ caches: cacheStorage,
+ importScripts: () => {
+ throw new Error("no generated version file in the sandbox");
+ },
+ // Default: network down. Individual tests override `context.fetch`.
+ fetch: async () => {
+ throw new TypeError("network down");
+ },
+ Response,
+ Request,
+ URL,
+ Promise,
+ console,
+ };
+ vm.createContext(context);
+ vm.runInContext(SW_SOURCE, context);
+ return { listeners, cacheStorage, navPreload, context };
+}
+
+async function dispatchActivate(harness: SwHarness): Promise {
+ let settled: Promise = Promise.resolve();
+ harness.listeners.get("activate")!({
+ waitUntil: (p: Promise) => {
+ settled = p;
+ },
+ });
+ await settled;
+}
+
+function dispatchNavigationFetch(
+ harness: SwHarness,
+ path: string,
+ preloadResponse?: Promise,
+): Promise {
+ let captured: Promise | null = null;
+ harness.listeners.get("fetch")!({
+ request: new Request(`${ORIGIN}${path}`, {
+ headers: { accept: "text/html" },
+ }),
+ respondWith: (p: Promise) => {
+ captured = p;
+ },
+ preloadResponse,
+ });
+ if (!captured) throw new Error("fetch handler did not respond");
+ return captured;
+}
+
+describe("sw.js — activate", () => {
+ it("drops stale-version caches and enables navigation preload", async () => {
+ const harness = bootServiceWorker();
+ const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
+ await stale.put(`${ORIGIN}/`, new Response("stale shell"));
+ await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
+
+ await dispatchActivate(harness);
+
+ expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe(
+ false,
+ );
+ expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true);
+ expect(harness.navPreload.enabled).toBe(true);
+ });
+});
+
+describe("sw.js — networkFirst offline fallback", () => {
+ it("never serves a shell cached under a previous CACHE_VERSION", async () => {
+ const harness = bootServiceWorker();
+ // A stale pre-update cache that survived into the activation gap.
+ const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
+ await stale.put(`${ORIGIN}/`, new Response("stale shell"));
+
+ const res = await dispatchNavigationFetch(harness, "/");
+
+ // The stale shell is ignored; the language-neutral offline page wins.
+ expect(res.status).toBe(503);
+ expect(await res.text()).toContain("Offline");
+ });
+
+ it("serves the current-version page cache when offline", async () => {
+ const harness = bootServiceWorker();
+ const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
+ await pages.put(`${ORIGIN}/`, new Response("current shell"));
+
+ const res = await dispatchNavigationFetch(harness, "/");
+ expect(await res.text()).toBe("current shell");
+ });
+
+ it("falls back to the current-version precached shell when the page cache misses", async () => {
+ const harness = bootServiceWorker();
+ const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
+ await statics.addAll(["/"]);
+
+ const res = await dispatchNavigationFetch(harness, "/");
+ expect(await res.text()).toBe("precached:/");
+ });
+
+ it("consumes the navigation-preload response instead of re-fetching", async () => {
+ const harness = bootServiceWorker();
+ let fetchCalls = 0;
+ harness.context.fetch = async () => {
+ fetchCalls += 1;
+ return new Response("network shell");
+ };
+
+ const res = await dispatchNavigationFetch(
+ harness,
+ "/",
+ Promise.resolve(new Response("preloaded shell")),
+ );
+
+ expect(await res.clone().text()).toBe("preloaded shell");
+ expect(fetchCalls).toBe(0);
+ // The preloaded response was cached under the current page cache.
+ const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
+ expect(await pages.match(`${ORIGIN}/`)).toBeDefined();
+ });
+});
+
+describe("sw.js — trimCache", () => {
+ it("reads the key list once and deletes only the oldest excess entries", async () => {
+ const harness = bootServiceWorker();
+ const store = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
+ for (let i = 0; i < 8; i++) {
+ await store.put(`${ORIGIN}/page-${i}`, new Response(`p${i}`));
+ }
+ store.keysCalls = 0;
+
+ const trimCache = (
+ harness.context as { trimCache?: (name: string, max: number) => Promise }
+ ).trimCache;
+ expect(typeof trimCache).toBe("function");
+ await trimCache!(CURRENT_PAGE_CACHE, 5);
+
+ // Single key read (the previous implementation re-read per deletion).
+ expect(store.keysCalls).toBe(1);
+ // Oldest three gone, newest five kept.
+ expect(store.map.has(`${ORIGIN}/page-0`)).toBe(false);
+ expect(store.map.has(`${ORIGIN}/page-2`)).toBe(false);
+ expect(store.map.has(`${ORIGIN}/page-3`)).toBe(true);
+ expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true);
+ });
+});
diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx
new file mode 100644
index 000000000..41504b2f7
--- /dev/null
+++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx
@@ -0,0 +1,187 @@
+/**
+ * v1.16.0 — dashboard first-load choreography.
+ *
+ * Two complaints from the maintainer's first-load walk-through:
+ *
+ * 1. The tile strip painted EMPTY dark cards while the primary query
+ * was in flight — no inner structure previewing the final tile.
+ * 2. The chart row popped in one chart at a time: every chart owns
+ * its own query, `/api/mood/analytics` is a single cheap read, so
+ * the mood chart reliably painted first and the measurement
+ * charts trickled in afterwards.
+ *
+ * The fix: structured `` silhouettes for every tile
+ * slot, plus a shared reveal gate (`useDashboardChartReveal` /
+ * ``) that holds every gated chart on its
+ * layout-stable skeleton until every chart's data settled — or the 2 s
+ * fallback fires so one slow widget cannot block the row.
+ *
+ * Project convention is SSR-only tests (vitest `node` environment, no
+ * DOM test runner): the gate logic is pinned through the pure
+ * `resolveChartRevealState` resolver, the markup through
+ * `renderToStaticMarkup`, and the page wiring through textual pins.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import {
+ CHART_REVEAL_TIMEOUT_MS,
+ DashboardChartCell,
+ resolveChartRevealState,
+} from "@/components/dashboard/chart-reveal";
+import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
+import { I18nProvider } from "@/lib/i18n/context";
+
+const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
+
+describe("resolveChartRevealState", () => {
+ it("stays hidden while the gate is not armed (no visible charts yet)", () => {
+ expect(
+ resolveChartRevealState({
+ expectedIds: [],
+ readyIds: new Set(),
+ timedOut: false,
+ }),
+ ).toBe(false);
+ // Even a stale timeout cannot reveal an empty row.
+ expect(
+ resolveChartRevealState({
+ expectedIds: [],
+ readyIds: new Set(),
+ timedOut: true,
+ }),
+ ).toBe(false);
+ });
+
+ it("holds the skeletons while any gated chart is still loading", () => {
+ expect(
+ resolveChartRevealState({
+ expectedIds: ["weight-chart", "mood-chart"],
+ readyIds: new Set(["mood-chart"]),
+ timedOut: false,
+ }),
+ ).toBe(false);
+ });
+
+ it("reveals once every gated chart reported ready (the Promise.all moment)", () => {
+ expect(
+ resolveChartRevealState({
+ expectedIds: ["weight-chart", "mood-chart"],
+ readyIds: new Set(["weight-chart", "mood-chart"]),
+ timedOut: false,
+ }),
+ ).toBe(true);
+ });
+
+ it("reveals on timeout so a slow widget cannot block the row", () => {
+ expect(
+ resolveChartRevealState({
+ expectedIds: ["weight-chart", "mood-chart"],
+ readyIds: new Set(),
+ timedOut: true,
+ }),
+ ).toBe(true);
+ });
+
+ it("pins the 2 s fallback budget", () => {
+ expect(CHART_REVEAL_TIMEOUT_MS).toBe(2_000);
+ });
+});
+
+describe("DashboardChartCell", () => {
+ const render = (revealed: boolean) =>
+ renderToStaticMarkup(
+
+
+ chart body
+
+ ,
+ );
+
+ it("hidden state: keeps the chart mounted but invisible under a skeleton overlay", () => {
+ const html = render(false);
+ expect(html).toContain('data-revealed="false"');
+ // Chart stays mounted (its query must fire) but is layout-only.
+ expect(html).toContain('data-slot="probe-chart"');
+ expect(html).toContain("invisible");
+ expect(html).toContain('aria-hidden="true"');
+ // Layout-stable skeleton overlays the same box.
+ expect(html).toContain('data-slot="chart-skeleton"');
+ expect(html).toContain("absolute inset-0");
+ });
+
+ it("revealed state: drops the skeleton and fades the content in (motion-safe)", () => {
+ const html = render(true);
+ expect(html).toContain('data-revealed="true"');
+ expect(html).not.toContain('data-slot="chart-skeleton"');
+ expect(html).not.toContain("invisible");
+ expect(html).toContain("motion-safe:animate-in");
+ expect(html).toContain("motion-safe:fade-in");
+ });
+});
+
+describe("TrendCardSkeleton", () => {
+ it("renders a structured silhouette, not an empty card", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('data-slot="trend-card-skeleton"');
+ expect(html).toContain('aria-hidden="true"');
+ // Structured: heading row + headline value + sub-row blocks.
+ const skeletonBlocks = html.match(/data-slot="skeleton"/g) ?? [];
+ expect(skeletonBlocks.length).toBeGreaterThanOrEqual(5);
+ // Same outer chrome + min-height floor as the real TrendCard slot.
+ expect(html).toContain("rounded-xl");
+ expect(html).toContain("min-h-[8rem]");
+ // Reduced motion honoured via the Skeleton primitive.
+ expect(html).toContain("motion-reduce:animate-none");
+ });
+});
+
+describe("dashboard chart-reveal wiring (page.tsx)", () => {
+ const src = readFileSync(PAGE_PATH, "utf8");
+
+ it("derives the gated id list from the chart visibility gates", () => {
+ expect(src).toMatch(
+ /const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/,
+ );
+ expect(src).toMatch(
+ /useDashboardChartReveal\(revealChartIds\)/,
+ );
+ });
+
+ it("every data-backed chart reports through onDataReady", () => {
+ for (const id of [
+ "weight-chart",
+ "bmi-chart",
+ "bp-chart",
+ "pulse-chart",
+ "bodyFat-chart",
+ "mood-chart",
+ "sleep-chart",
+ "steps-chart",
+ "medications",
+ ]) {
+ expect(src).toContain(`onDataReady={() => markChartReady("${id}")}`);
+ }
+ });
+
+ it("gated entries render inside DashboardChartCell", () => {
+ expect(src).toMatch(
+ /entry\.revealGated\s*\?[\s\S]*?/,
+ );
+ });
+
+ it("self-skeletoning cards (achievements / workouts) stay outside the gate", () => {
+ // Their entries never set `revealGated`, so the ungated branch
+ // renders them directly.
+ const achievements = src.match(
+ /charts\.push\(\{\s*id:\s*"achievements"[\s\S]*?\}\);/,
+ );
+ const workouts = src.match(
+ /charts\.push\(\{\s*id:\s*"recentWorkouts"[\s\S]*?\}\);/,
+ );
+ expect(achievements?.[0]).not.toContain("revealGated");
+ expect(workouts?.[0]).not.toContain("revealGated");
+ });
+});
diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts
index aa1a62d25..9db552cbe 100644
--- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts
+++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts
@@ -50,13 +50,16 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("wraps each tile-strip cell in a `` boundary with a layout-stable placeholder", () => {
const src = load(PAGE_PATH);
// v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null`
- // fallback for an `aria-hidden` placeholder div that mirrors the
- // trend-card chrome. The tile body is synchronous today so the
- // fallback rarely paints, but a future RSC hoist of any tile slot
- // would otherwise leave the grid track empty and trigger CLS as
- // the cell paints in. The structural pin guards the contract.
+ // fallback for an `aria-hidden` placeholder div; v1.16.0 upgraded
+ // it to the structured `` silhouette (the
+ // aria-hidden + trend-card chrome now live inside the component,
+ // see `trend-card-skeleton.tsx`). The tile body is synchronous
+ // today so the fallback rarely paints, but a future RSC hoist of
+ // any tile slot would otherwise leave the grid track empty and
+ // trigger CLS as the cell paints in. The structural pin guards the
+ // contract.
expect(src).toMatch(
- /\s*\{entry\.node\}\s*<\/Suspense>/,
+ /\}>\s*\{entry\.node\}\s*<\/Suspense>/,
);
});
diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
index 75ca1a0b1..3bf6a6583 100644
--- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
+++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts
@@ -130,7 +130,12 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
);
});
- it("marks the skeleton aria-hidden + carries motion-reduce", () => {
+ it("marks the skeleton aria-hidden + renders structured silhouettes", () => {
+ // v1.16.0 — the strip silhouettes are structured ``
+ // cards (label + headline value + sub-row), not empty pulsing divs.
+ // Reduced motion moved into the component (the `` primitive
+ // carries `motion-reduce:animate-none`); the per-component test in
+ // `dashboard-chart-reveal.test.tsx` pins that.
const src = readFileSync(PAGE_PATH, "utf8");
const skeletonBlock = src.match(
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
@@ -138,7 +143,14 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
expect(skeletonBlock).not.toBeNull();
const block = skeletonBlock?.[0] ?? "";
expect(block).toContain('aria-hidden="true"');
- expect(block).toContain("motion-reduce:animate-none");
+ expect(block).toContain(" {
+ const src = readFileSync(PAGE_PATH, "utf8");
+ expect(src).toMatch(
+ /\}>/,
+ );
});
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx
index 304cbf512..f7bb0234e 100644
--- a/src/app/admin/[section]/renderer.tsx
+++ b/src/app/admin/[section]/renderer.tsx
@@ -31,6 +31,7 @@ import { LoginOverviewSection } from "@/components/admin/login-overview-section"
import { RemindersSection } from "@/components/admin/reminders-section";
import { ServicesSection } from "@/components/admin/services-section";
import { SystemStatusSection } from "@/components/admin/system-status-section";
+import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
import { UserManagementSection } from "@/components/admin/user-management-section";
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
@@ -141,6 +142,18 @@ export function AdminSectionRenderer({
);
+ case "invites":
+ // v1.16.0 — invites moved out of Users into their own section:
+ // the full table (status, redemptions, revocation) is a workflow
+ // of its own and was drowning under the account list.
+ return (
+
+
+
+ );
case "api-tokens":
return (
}) => {
+ const { user } = await requireAdmin();
+ const { id } = await params;
+
+ const { count } = await prisma.inviteToken.updateMany({
+ where: { id, revokedAt: null },
+ data: { revokedAt: new Date() },
+ });
+ const revoked = count > 0;
+
+ if (revoked) {
+ await auditLog("admin.invite.revoked", {
+ userId: user.id,
+ ipAddress: getClientIp(req),
+ details: { inviteId: id },
+ });
+ }
+
+ annotate({
+ action: { name: "admin.invite.revoked" },
+ meta: { revoked },
+ });
+
+ return apiSuccess({ revoked });
+ },
+);
diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts
new file mode 100644
index 000000000..23df2716d
--- /dev/null
+++ b/src/app/api/admin/invites/route.ts
@@ -0,0 +1,121 @@
+/**
+ * GET /api/admin/invites — list all registration invites.
+ * POST /api/admin/invites — mint a new invite token.
+ *
+ * v1.15.20 — closed-registration invite flow. The raw `hlv_` token
+ * appears EXACTLY ONCE: in the POST response, alongside the composed
+ * registration URL. Only the HMAC-SHA256 hash is persisted (the
+ * `ApiToken` scheme), so neither the GET list nor a database leak can
+ * reproduce a usable invite.
+ *
+ * Both verbs are `requireAdmin()` — cookie-only by construction; a
+ * Bearer token can never mint or enumerate invites.
+ */
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAdmin } from "@/lib/api-handler";
+import {
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { hashToken } from "@/lib/auth/hmac";
+import {
+ buildInviteUrl,
+ generateInviteToken,
+} from "@/lib/auth/invite-token";
+import { inviteCreateSchema } from "@/lib/validations/invite";
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(async () => {
+ await requireAdmin();
+ annotate({ action: { name: "admin.invites.list" } });
+
+ const invites = await prisma.inviteToken.findMany({
+ select: {
+ id: true,
+ createdAt: true,
+ expiresAt: true,
+ usedAt: true,
+ revokedAt: true,
+ uses: true,
+ maxUses: true,
+ creator: { select: { id: true, username: true } },
+ consumer: { select: { id: true, username: true } },
+ // v1.16.0 — full per-signup history. `consumer` only carries the
+ // LAST account; multi-use invites list every admitted signup here.
+ redemptions: {
+ select: {
+ id: true,
+ redeemedAt: true,
+ user: { select: { id: true, username: true, email: true } },
+ },
+ orderBy: { redeemedAt: "desc" },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ });
+
+ return apiSuccess(invites);
+});
+
+export const POST = apiHandler(async (req: Request) => {
+ const { user } = await requireAdmin();
+
+ const { data: body, error: jsonError } = await safeJson(req, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = inviteCreateSchema.safeParse(body ?? {});
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const { expiresInDays, maxUses } = parsed.data;
+ const rawToken = generateInviteToken();
+ const expiresAt = new Date(
+ Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
+ );
+
+ const invite = await prisma.inviteToken.create({
+ data: {
+ tokenHash: hashToken(rawToken),
+ createdBy: user.id,
+ expiresAt,
+ maxUses,
+ },
+ select: {
+ id: true,
+ createdAt: true,
+ expiresAt: true,
+ uses: true,
+ maxUses: true,
+ },
+ });
+
+ // The audit row carries metadata only — never the raw token.
+ await auditLog("admin.invite.created", {
+ userId: user.id,
+ ipAddress: getClientIp(req),
+ details: { inviteId: invite.id, expiresInDays, maxUses },
+ });
+
+ annotate({
+ action: { name: "admin.invite.created" },
+ meta: { expiresInDays, maxUses },
+ });
+
+ return apiSuccess(
+ {
+ ...invite,
+ // Shown exactly once. Neither value is persisted in plaintext.
+ token: rawToken,
+ url: buildInviteUrl(rawToken, req.url),
+ },
+ 201,
+ );
+});
diff --git a/src/app/api/analytics/range/route.ts b/src/app/api/analytics/range/route.ts
index bebb0a292..4788c0107 100644
--- a/src/app/api/analytics/range/route.ts
+++ b/src/app/api/analytics/range/route.ts
@@ -20,8 +20,9 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
+import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
+import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
ANALYTICS_RANGES,
@@ -40,6 +41,12 @@ const rangeQuerySchema = z.object({
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
+ // v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
+ const rl = await checkAnalyticsReadRateLimit(user.id);
+ if (!rl.allowed) {
+ return apiError("Too many analytics requests. Please retry later.", 429);
+ }
+
const parsed = rangeQuerySchema.safeParse({
type: request.nextUrl.searchParams.get("type"),
range: request.nextUrl.searchParams.get("range"),
diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts
index b6ba3810f..3e8d598b9 100644
--- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts
+++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts
@@ -91,6 +91,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
});
});
@@ -111,6 +112,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
});
});
@@ -133,6 +135,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
});
});
});
@@ -166,6 +169,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -175,6 +179,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
},
},
});
@@ -188,11 +193,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
},
next: {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
},
changed: ["medication"],
}),
@@ -254,6 +261,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
},
},
});
@@ -279,6 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -288,6 +297,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
medication: { clientManaged: true, deliveryDefault: "server" },
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
},
},
});
@@ -316,6 +326,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
medication: { clientManaged: false, deliveryDefault: "server" },
mood: { reminderHour: 9 },
cycle: { clientManaged: false },
+ coach: { nudgesEnabled: true },
},
},
});
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 22b43748f..429d331d8 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -67,6 +67,9 @@ export const GET = apiHandler(async () => {
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
unitPreference: user.unitPreference === "imperial" ? "imperial" : "metric",
+ // Hour-cycle display preference (AUTO follows the locale convention,
+ // H12 / H24 pin the cycle). Clients mirror this into their formatters.
+ timeFormat: user.timeFormat ?? "AUTO",
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index 3a3080207..d287a80a7 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -1,4 +1,8 @@
import { prisma } from "@/lib/db";
+import {
+ consumeInviteToken,
+ recordInviteConsumer,
+} from "@/lib/auth/invite-token";
import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
@@ -57,10 +61,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
} catch {
// Table may not exist yet; allow registration
}
- if (!registrationEnabled) {
- return apiError("Registration is disabled", 403);
- }
-
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -73,7 +73,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
- const { email, username, password, timezone: timezoneInput } = parsed.data;
+ const {
+ email,
+ username,
+ password,
+ timezone: timezoneInput,
+ inviteToken,
+ } = parsed.data;
+
+ // v1.15.20 — closed registration still admits a valid invite. The
+ // token itself is 32 random bytes behind a keyed hash and the surface
+ // is rate-limited (5 / 15 min / IP), so a distinct "invalid invite"
+ // message is a UX win, not an oracle.
+ if (!registrationEnabled && !inviteToken) {
+ return apiError("Registration is disabled", 403);
+ }
// v1.4.25 W7 — accept a browser-detected timezone from the
// registration form. Validate against the runtime IANA list; on
@@ -109,6 +123,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const passwordHash = await hashPassword(password);
+ // v1.15.20 — consume the invite LAST, after every other validation,
+ // so a taken username or weak password can never burn one of the
+ // invite's uses. The guarded increment inside `consumeInviteToken`
+ // makes the last-use race safe under concurrent signups.
+ let inviteId: string | null = null;
+ if (!registrationEnabled && inviteToken) {
+ const consumed = await consumeInviteToken(inviteToken);
+ if (!consumed.ok) {
+ annotate({
+ action: { name: "auth.register.invite_rejected" },
+ meta: { reason: consumed.reason },
+ });
+ return apiError("Invalid or expired invite", 403);
+ }
+ inviteId = consumed.inviteId;
+ }
+
// First user becomes admin
const role = userCount === 0 ? "ADMIN" : "USER";
@@ -130,10 +161,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ua = request.headers.get("user-agent");
await createSession(user.id, true, ip, ua);
+ // Stamp the consumer onto the invite (informational, best-effort —
+ // the use itself was already counted atomically above).
+ if (inviteId) {
+ await recordInviteConsumer(inviteId, user.id);
+ }
+
await auditLog("auth.register", {
userId: user.id,
ipAddress: ip,
- details: { method: "password", timezone },
+ details: { method: "password", timezone, invited: inviteId !== null },
});
annotate({ action: { name: "auth.register" } });
diff --git a/src/app/api/coach/about-me/questions/route.ts b/src/app/api/coach/about-me/questions/route.ts
new file mode 100644
index 000000000..1c4094d21
--- /dev/null
+++ b/src/app/api/coach/about-me/questions/route.ts
@@ -0,0 +1,72 @@
+/**
+ * GET /api/coach/about-me/questions — pending clarifying questions.
+ * DELETE /api/coach/about-me/questions — dismiss one (body
+ * `{ question }`) or all (empty body / `{}`).
+ *
+ * v1.16.0 — the Coach composer renders the pending questions as
+ * tappable suggestion chips. Tapping a chip inserts the question into
+ * the composer AND dismisses it here; the small ✕ on a chip dismisses
+ * without inserting. The questions are user-visible suggestion text
+ * only — stored encrypted, scoped to the caller, never interpreted
+ * server-side beyond exact-match removal.
+ */
+import { z } from "zod/v4";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiSuccess,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import {
+ getPendingQuestionsForUser,
+ PENDING_QUESTION_MAX_CHARS,
+ setPendingQuestionsForUser,
+} from "@/lib/ai/coach/about-me";
+
+const dismissSchema = z.object({
+ /** Exact question text to dismiss. Omitted = dismiss all. */
+ question: z.string().max(PENDING_QUESTION_MAX_CHARS).optional(),
+});
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ const questions = await getPendingQuestionsForUser(user.id);
+ annotate({
+ action: { name: "coach.about_me.questions.get" },
+ meta: { count: questions.length },
+ });
+ return apiSuccess({ questions });
+});
+
+export const DELETE = apiHandler(async (req: Request) => {
+ const { user } = await requireAuth();
+
+ const { data: body, error: jsonError } = await safeJson(req, {
+ maxBytes: 4 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = dismissSchema.safeParse(body ?? {});
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const current = await getPendingQuestionsForUser(user.id);
+ const remaining =
+ parsed.data.question === undefined
+ ? []
+ : current.filter((q) => q !== parsed.data.question);
+ await setPendingQuestionsForUser(user.id, remaining);
+
+ annotate({
+ action: { name: "coach.about_me.questions.dismissed" },
+ meta: { before: current.length, after: remaining.length },
+ });
+
+ return apiSuccess({ questions: remaining });
+});
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts
new file mode 100644
index 000000000..3c2b18bc2
--- /dev/null
+++ b/src/app/api/coach/about-me/route.ts
@@ -0,0 +1,225 @@
+/**
+ * GET /api/coach/about-me — the caller's structured self-context.
+ * PUT /api/coach/about-me — write (or clear) the self-context.
+ *
+ * v1.15.20 — free-text "about me", encrypted at rest.
+ * v1.16.0 — extended with three structured fields (conditions,
+ * allergies, coach focus; each ≤500 chars, encrypted) and the
+ * clarifying-questions loop: after a save the server derives up to 3
+ * follow-up questions (AI when a provider + the daily Coach budget
+ * allow, deterministic hints otherwise) and persists them encrypted;
+ * the Coach composer renders them as tappable chips
+ * (`/api/coach/about-me/questions`).
+ *
+ * PUT field semantics: `aboutMe` is required (empty string clears).
+ * The structured fields are optional — omitted leaves the stored value
+ * untouched, an empty string clears it — so older clients that only
+ * send `aboutMe` keep working unchanged.
+ *
+ * Plain text end to end: the client renders every value as a React
+ * text child only — no markdown library exists in the tree and none
+ * may be added (XSS posture, see the contributor notes).
+ *
+ * Ownership: the user id always comes from `requireAuth()`; the body
+ * carries only the text. Audit rows never contain the text itself —
+ * only per-field lengths — because it is free-form health prose.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma } from "@/lib/db";
+import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
+import {
+ getPendingQuestionsForUser,
+ getSelfContextForUser,
+ setPendingQuestionsForUser,
+} from "@/lib/ai/coach/about-me";
+import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
+import {
+ ABOUT_ME_FIELD_MAX_CHARS,
+ ABOUT_ME_MAX_CHARS,
+ aboutMePutSchema,
+} from "@/lib/validations/about-me";
+
+const PUT_RATE_LIMIT = 30;
+const PUT_WINDOW_MS = 60_000;
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+
+ const [ctx, pendingQuestions, row] = await Promise.all([
+ getSelfContextForUser(user.id),
+ getPendingQuestionsForUser(user.id),
+ prisma.userHealthProfile.findUnique({
+ where: { userId: user.id },
+ select: { updatedAt: true },
+ }),
+ ]);
+
+ annotate({
+ action: { name: "coach.about_me.get" },
+ meta: {
+ present: ctx.aboutMe !== null,
+ structured:
+ ctx.conditions !== null ||
+ ctx.allergies !== null ||
+ ctx.coachFocus !== null,
+ },
+ });
+
+ return apiSuccess({
+ aboutMe: ctx.aboutMe,
+ conditions: ctx.conditions,
+ allergies: ctx.allergies,
+ coachFocus: ctx.coachFocus,
+ pendingQuestions,
+ updatedAt: row?.updatedAt?.toISOString() ?? null,
+ maxChars: ABOUT_ME_MAX_CHARS,
+ fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
+ });
+});
+
+export const PUT = apiHandler(async (req: Request) => {
+ const { user } = await requireAuth();
+
+ const rl = await checkRateLimit(
+ `coach-about-me:put:${user.id}`,
+ PUT_RATE_LIMIT,
+ PUT_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ const response = apiError("Too many requests", 429);
+ for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
+ response.headers.set(k, v);
+ }
+ return response;
+ }
+
+ const { data: body, error: jsonError } = await safeJson(req, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = aboutMePutSchema.safeParse(body);
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const text = parsed.data.aboutMe.trim();
+ const cleared = text.length === 0;
+
+ // Field-by-field data assembly (no mass assignment): omitted fields
+ // never appear in the update object, so they stay untouched.
+ const encryptOptional = (
+ raw: string | undefined,
+ ): Uint8Array | null | undefined => {
+ if (raw === undefined) return undefined;
+ const value = raw.trim();
+ return value.length === 0 ? null : encryptToBytes(value);
+ };
+ const update: {
+ aboutMeEncrypted: Uint8Array | null;
+ conditionsEncrypted?: Uint8Array | null;
+ allergiesEncrypted?: Uint8Array | null;
+ coachFocusEncrypted?: Uint8Array | null;
+ } = {
+ aboutMeEncrypted: cleared ? null : encryptToBytes(text),
+ };
+ const conditionsPayload = encryptOptional(parsed.data.conditions);
+ if (conditionsPayload !== undefined) {
+ update.conditionsEncrypted = conditionsPayload;
+ }
+ const allergiesPayload = encryptOptional(parsed.data.allergies);
+ if (allergiesPayload !== undefined) {
+ update.allergiesEncrypted = allergiesPayload;
+ }
+ const coachFocusPayload = encryptOptional(parsed.data.coachFocus);
+ if (coachFocusPayload !== undefined) {
+ update.coachFocusEncrypted = coachFocusPayload;
+ }
+ const fieldLengths: Record = {
+ aboutMe: text.length,
+ ...(parsed.data.conditions !== undefined
+ ? { conditions: parsed.data.conditions.trim().length }
+ : {}),
+ ...(parsed.data.allergies !== undefined
+ ? { allergies: parsed.data.allergies.trim().length }
+ : {}),
+ ...(parsed.data.coachFocus !== undefined
+ ? { coachFocus: parsed.data.coachFocus.trim().length }
+ : {}),
+ };
+
+ const row = await prisma.userHealthProfile.upsert({
+ where: { userId: user.id },
+ create: { userId: user.id, ...update },
+ update,
+ select: { updatedAt: true },
+ });
+
+ // Read back the effective state (covers omitted fields) and derive
+ // the clarifying questions. An entirely empty self-context clears
+ // the pending questions instead of generating new ones.
+ const ctx = await getSelfContextForUser(user.id);
+ const isEmpty =
+ ctx.aboutMe === null &&
+ ctx.conditions === null &&
+ ctx.allergies === null &&
+ ctx.coachFocus === null;
+
+ let pendingQuestions: string[] = [];
+ let questionsSource: "ai" | "fallback" | "none" = "none";
+ if (isEmpty) {
+ await setPendingQuestionsForUser(user.id, null);
+ } else {
+ const derived = await deriveClarifyingQuestions(user.id, ctx, user.locale);
+ pendingQuestions = derived.questions;
+ questionsSource = derived.source;
+ await setPendingQuestionsForUser(user.id, pendingQuestions);
+ }
+
+ // The audit row carries per-field lengths only — the text is
+ // free-form health prose and must not land in the plaintext audit
+ // details.
+ await auditLog(
+ isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
+ {
+ userId: user.id,
+ ipAddress: getClientIp(req),
+ details: { ...fieldLengths },
+ },
+ );
+
+ annotate({
+ action: {
+ name: isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
+ },
+ meta: {
+ length: text.length,
+ questions_source: questionsSource,
+ questions_count: pendingQuestions.length,
+ },
+ });
+
+ return apiSuccess({
+ aboutMe: ctx.aboutMe,
+ conditions: ctx.conditions,
+ allergies: ctx.allergies,
+ coachFocus: ctx.coachFocus,
+ pendingQuestions,
+ updatedAt: row.updatedAt.toISOString(),
+ maxChars: ABOUT_ME_MAX_CHARS,
+ fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
+ });
+});
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 90acb8440..3b77cbfd1 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -64,6 +64,7 @@ import {
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
+import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
@@ -313,7 +314,14 @@ async function handleChatRequest(request: NextRequest): Promise {
? { ...(scope ?? {}), window: coachPrefs.defaultWindow }
: scope;
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
- const systemPrompt = getCoachSystemPrompt(locale, coachPrefs);
+ // v1.15.20 — the user-authored "about me" self-description (Settings →
+ // AI) rides the system prompt as a delimited, user-provided context
+ // block. Fail-open: a missing / undecryptable text yields null and the
+ // prompt is byte-identical to the pre-feature one.
+ // v1.16.0 — composed self-context: structured questionnaire fields
+ // plus age/gender merged in from the User profile.
+ const aboutMe = await getSelfContextTextForUser(userId, locale);
+ const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe);
const allTurns: CoachTurn[] = [
...priorTurns,
{ role: "user", content: message },
diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts
index 637a40e5a..c9f7d5033 100644
--- a/src/app/api/insights/comprehensive/__tests__/route.test.ts
+++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts
@@ -50,6 +50,12 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
+// v1.15.20 — the route checks the shared analytics-read budget before any
+// DB work; the real helper would hit the unmocked `$queryRaw`.
+vi.mock("@/lib/rate-limit", () => ({
+ checkAnalyticsReadRateLimit: vi.fn(),
+}));
+
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -84,6 +90,7 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
+import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -104,6 +111,12 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
__resetAllCachesForTests();
+ // v1.15.20 — default to an allowing analytics-read budget.
+ vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 119,
+ resetAt: Date.now() + 60_000,
+ });
// Default to assistant-on so the gate doesn't 403 every test.
(
prisma.appSettings.findUnique as ReturnType
diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts
index 9a9042b28..44a509689 100644
--- a/src/app/api/insights/comprehensive/route.ts
+++ b/src/app/api/insights/comprehensive/route.ts
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
-import { apiSuccess } from "@/lib/api-response";
+import { apiError, apiSuccess } from "@/lib/api-response";
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
import { summarize } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -18,11 +18,13 @@ import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
+ SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { getMedicationCategories } from "@/lib/medication-category";
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
+import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import {
@@ -34,6 +36,13 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
+
+ // v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
+ const rl = await checkAnalyticsReadRateLimit(user.id);
+ if (!rl.allowed) {
+ return apiError("Too many analytics requests. Please retry later.", 429);
+ }
+
// v1.4.31 — comprehensive feeds the hero strip narration and the
// recommendations grid that share the Coach gate.
await requireAssistantSurface("coach");
@@ -250,7 +259,9 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// Bounded reads — left untouched per the directive.
const medications = await prisma.medication.findMany({
where: { userId, active: true },
- include: { schedules: true },
+ // v1.15.20 — schedules through the shared compliance select so the
+ // configured per-dose windows reach this surface like every other.
+ include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
});
const categoryMap = await getMedicationCategories(
medications.map((m) => m.id),
diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts
index ae31fdea0..9933d4ec1 100644
--- a/src/app/api/insights/correlations/__tests__/route.test.ts
+++ b/src/app/api/insights/correlations/__tests__/route.test.ts
@@ -17,6 +17,12 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
+// v1.15.20 — the route checks the shared analytics-read budget before any
+// DB work; the real helper would hit the unmocked `$queryRaw`.
+vi.mock("@/lib/rate-limit", () => ({
+ checkAnalyticsReadRateLimit: vi.fn(),
+}));
+
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -35,6 +41,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
+import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -43,6 +50,12 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
+ // v1.15.20 — default to an allowing analytics-read budget.
+ vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 119,
+ resetAt: Date.now() + 60_000,
+ });
// v1.4.31 — default to an all-on flag set so existing assertions
// ride through unchanged. Tests that need the gate-off path set
// their own findUnique resolution.
@@ -118,4 +131,20 @@ describe("GET /api/insights/correlations", () => {
const res = await callGet(makeReq());
expect(res.status).toBe(403);
});
+
+ it("returns 429 when the shared analytics-read budget is exhausted", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 60_000,
+ });
+ const res = await callGet(makeReq());
+ expect(res.status).toBe(429);
+ expect(vi.mocked(checkAnalyticsReadRateLimit)).toHaveBeenCalledWith(
+ "user-1",
+ );
+ // The limited request never reaches the series reads.
+ expect(prisma.measurement.findMany).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts
index d628e588f..eae72346b 100644
--- a/src/app/api/insights/correlations/route.ts
+++ b/src/app/api/insights/correlations/route.ts
@@ -16,8 +16,9 @@
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
-import { apiSuccess } from "@/lib/api-response";
+import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
+import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { getLocalDateParts } from "@/lib/timezone";
@@ -65,6 +66,13 @@ function toDailyMeans(
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
+
+ // v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
+ const rl = await checkAnalyticsReadRateLimit(user.id);
+ if (!rl.allowed) {
+ return apiError("Too many analytics requests. Please retry later.", 429);
+ }
+
// Operator can hide the correlation surface entirely.
await requireAssistantSurface("correlations");
diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts
index 7711d0fcf..fca913f30 100644
--- a/src/app/api/insights/derived/__tests__/route.test.ts
+++ b/src/app/api/insights/derived/__tests__/route.test.ts
@@ -17,6 +17,17 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
+// v1.15.20 — the route checks the shared analytics-read budget before any
+// DB work; the real helper would hit the unmocked `$queryRaw`. This file
+// uses `clearAllMocks` (implementations survive), so the factory default
+// is enough.
+vi.mock("@/lib/rate-limit", () => ({
+ checkAnalyticsReadRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 119,
+ resetAt: 0,
+ }),
+}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts
index 0cc883acf..8c0f9d0e8 100644
--- a/src/app/api/insights/derived/route.ts
+++ b/src/app/api/insights/derived/route.ts
@@ -20,9 +20,10 @@
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
-import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
+import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import {
@@ -50,6 +51,13 @@ const derivedQuerySchema = z.object({
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
+
+ // v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
+ const rl = await checkAnalyticsReadRateLimit(user.id);
+ if (!rl.allowed) {
+ return apiError("Too many analytics requests. Please retry later.", 429);
+ }
+
await requireAssistantSurface("insightStatus");
const parsed = derivedQuerySchema.safeParse({
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 5b59b3ad3..9bec76511 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -26,6 +26,10 @@ import {
type ComparisonSnapshot,
} from "@/lib/ai/prompts/insight-system-prompt";
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
+import {
+ buildAboutMeInsightBlock,
+ getSelfContextTextForUser,
+} from "@/lib/ai/coach/about-me";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
@@ -426,7 +430,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the
// serialised payload. If the raw (bucketed) shape ever blows past
// it (regression watch — the v1.4.35 rawMeasurements shape hit
- // 25.9 MB on Marc's account), the helper throws
+ // 25.9 MB on the largest tenant), the helper throws
// `FeaturesPayloadTooLargeError`. We downgrade to the aggregated
// shape rather than 500-ing so the user still gets an insight; the
// annotate event surfaces the regression to ops via the Logflare
@@ -544,6 +548,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (plateauContext) {
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
+ // v1.15.20 — fold the user-authored "about me" self-description
+ // (Settings → AI) into the briefing as a delimited, user-provided
+ // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing.
+ const aboutMe = await getSelfContextTextForUser(userId, locale);
+ if (aboutMe) {
+ userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
+ }
// v1.10.0 — fold a notable derived wellness signal (readiness / recovery
// shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block.
// Reads the one derived contract; null (the common case) costs nothing.
diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts
index dbbe4133d..56e58c119 100644
--- a/src/app/api/insights/targets/route.ts
+++ b/src/app/api/insights/targets/route.ts
@@ -15,6 +15,7 @@ import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
+ SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import {
@@ -1004,7 +1005,9 @@ async function buildTargetsResponse(user: AuthedUser) {
// 8. Medication Compliance (average across active medications)
const activeMedications = await prisma.medication.findMany({
where: { userId, active: true },
- include: { schedules: true },
+ // v1.15.20 — schedules through the shared compliance select so the
+ // configured per-dose windows reach this surface like every other.
+ include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
orderBy: { name: "asc" },
});
diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts
index a4ad7410f..34832172c 100644
--- a/src/app/api/medications/[id]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/__tests__/route.test.ts
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
update: vi.fn(),
},
+ medicationIntakeEvent: {
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
+ },
apiToken: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
@@ -176,6 +179,153 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
});
});
+describe("PUT /api/medications/[id] — schedule replace migrates open slots", () => {
+ beforeEach(() => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue({
+ id: "m1",
+ userId: "user-1",
+ active: true,
+ } as never);
+ vi.mocked(prisma.medication.update).mockResolvedValue({
+ id: "m1",
+ userId: "user-1",
+ schedules: [],
+ } as never);
+ vi.mocked(getMedicationCategories).mockResolvedValue({});
+ vi.mocked(auditLog).mockResolvedValue(undefined);
+ vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
+ count: 1,
+ } as never);
+ });
+
+ it("tombstones open pending rows (today + future) on a schedule replace", async () => {
+ const res = await PUT(
+ putReq({
+ schedules: [{ windowStart: "20:00", windowEnd: "20:30" }],
+ }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(200);
+ expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
+ const arg = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
+ .calls[0][0] as {
+ where: Record;
+ data: Record;
+ };
+ // Only live, never-acted rows from today's local day-start forward —
+ // taken / skipped / auto-missed history must survive a schedule edit.
+ expect(arg.where).toMatchObject({
+ userId: "user-1",
+ medicationId: "m1",
+ deletedAt: null,
+ takenAt: null,
+ skipped: false,
+ autoMissed: false,
+ });
+ expect(
+ (arg.where.scheduledFor as { gte: Date }).gte,
+ ).toBeInstanceOf(Date);
+ expect(arg.data.deletedAt).toBeInstanceOf(Date);
+ expect(arg.data.syncVersion).toEqual({ increment: 1 });
+ });
+
+ it("does NOT touch intake rows when the body carries no schedules array", async () => {
+ const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX);
+ expect(res.status).toBe(200);
+ expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
+ });
+});
+
+describe("PUT /api/medications/[id] — window / timesOfDay consistency", () => {
+ beforeEach(() => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue({
+ id: "m1",
+ userId: "user-1",
+ active: true,
+ } as never);
+ vi.mocked(prisma.medication.update).mockResolvedValue({
+ id: "m1",
+ userId: "user-1",
+ schedules: [],
+ } as never);
+ vi.mocked(getMedicationCategories).mockResolvedValue({});
+ vi.mocked(auditLog).mockResolvedValue(undefined);
+ vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
+ count: 0,
+ } as never);
+ });
+
+ function createdSchedule(): Record {
+ const calls = vi.mocked(prisma.medication.update).mock.calls;
+ expect(calls.length).toBeGreaterThan(0);
+ const arg = calls[calls.length - 1][0] as unknown as {
+ data: { schedules: { create: Array> } };
+ };
+ return arg.data.schedules.create[0];
+ }
+
+ it("pulls a stale window to the min/max of new timesOfDay", async () => {
+ // The client changed only the dose times and echoed the old window
+ // back; 21:00 falls outside [08:00, 09:00].
+ const res = await PUT(
+ putReq({
+ schedules: [
+ {
+ windowStart: "08:00",
+ windowEnd: "09:00",
+ timesOfDay: ["07:00", "21:00"],
+ },
+ ],
+ }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(200);
+ const created = createdSchedule();
+ expect(created.windowStart).toBe("07:00");
+ expect(created.windowEnd).toBe("21:00");
+ });
+
+ it("keeps a window that already covers every time byte-identical", async () => {
+ const res = await PUT(
+ putReq({
+ schedules: [
+ {
+ windowStart: "06:00",
+ windowEnd: "22:00",
+ timesOfDay: ["08:00", "20:00"],
+ },
+ ],
+ }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(200);
+ const created = createdSchedule();
+ expect(created.windowStart).toBe("06:00");
+ expect(created.windowEnd).toBe("22:00");
+ });
+
+ it("keeps an overnight window whose times sit inside the wrap", async () => {
+ const res = await PUT(
+ putReq({
+ schedules: [
+ {
+ windowStart: "22:00",
+ windowEnd: "02:00",
+ timesOfDay: ["23:00"],
+ },
+ ],
+ }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(200);
+ const created = createdSchedule();
+ expect(created.windowStart).toBe("22:00");
+ expect(created.windowEnd).toBe("02:00");
+ });
+});
+
describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts
index 5bff9ba7a..91911c24b 100644
--- a/src/app/api/medications/[id]/cadence/route.ts
+++ b/src/app/api/medications/[id]/cadence/route.ts
@@ -31,7 +31,10 @@ import {
type CadenceEngineContext,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
-import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
+import {
+ lastNonSkippedTakenAt,
+ SCHEDULE_COMPLIANCE_SELECT,
+} from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -52,7 +55,9 @@ export const GET = apiHandler(
const med = await prisma.medication.findUnique({
where: { id },
- include: { schedules: true },
+ // v1.15.20 — schedules through the shared compliance select so the
+ // configured per-dose windows reach this surface like every other.
+ include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
});
if (!med) {
return apiError("Medication not found", 404);
@@ -81,7 +86,14 @@ export const GET = apiHandler(
deletedAt: null,
scheduledFor: { gte: from },
},
- select: { scheduledFor: true, takenAt: true, skipped: true },
+ select: {
+ scheduledFor: true,
+ takenAt: true,
+ skipped: true,
+ // v1.15.20 — pinned takes bind by anchor in the chip tally so the
+ // chips agree with the % + the history on a "zugeordnet" dose.
+ attributionSource: true,
+ },
orderBy: { scheduledFor: "asc" },
});
diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts
index 4541af588..86dcc6585 100644
--- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts
@@ -33,6 +33,26 @@ vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
+// v1.15.20 — the route reads through the per-user compliance cache and a
+// per-user rate limit. Pass-through mocks keep each test's fixture
+// isolated (the real module-scope cache would leak state across tests);
+// the rate-limit tests below flip `allowed` explicitly.
+vi.mock("@/lib/cache/server-cache", () => ({
+ cached: vi.fn(
+ async (_cache: unknown, _key: string, builder: () => Promise) =>
+ builder(),
+ ),
+ caches: { medicationCompliance: {} },
+}));
+
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 59,
+ resetAt: Date.now() + 60_000,
+ }),
+}));
+
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -51,6 +71,8 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
+import { cached } from "@/lib/cache/server-cache";
+import { checkRateLimit } from "@/lib/rate-limit";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
@@ -106,6 +128,17 @@ function medication(schedules: unknown[]) {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ // `resetAllMocks` clears the factory implementations — re-prime the
+ // pass-through cache and the allowing rate limit for every test.
+ vi.mocked(cached).mockImplementation(
+ async (_cache: unknown, _key: string, builder: () => Promise) =>
+ builder(),
+ );
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ remaining: 59,
+ resetAt: Date.now() + 60_000,
+ });
});
async function callRoute(): Promise> {
@@ -353,3 +386,86 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
expect(display.longDays).toBeGreaterThan(30);
});
});
+
+// v1.15.20 — server cache + bounded event read + per-user rate limit.
+describe("GET /api/medications/[id]/compliance — caching and limits", () => {
+ function mockDailyMed() {
+ vi.mocked(prisma.medication.findUnique).mockResolvedValue(
+ medication([
+ {
+ id: "sched-1",
+ windowStart: "08:00",
+ windowEnd: "09:00",
+ timesOfDay: ["08:00"],
+ daysOfWeek: null,
+ rrule: null,
+ rollingIntervalDays: null,
+ reminderGraceMinutes: null,
+ scheduleType: "SCHEDULED",
+ cyclicOnWeeks: null,
+ cyclicOffWeeks: null,
+ },
+ ]) as never,
+ );
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+ [] as never,
+ );
+ }
+
+ it("returns 429 when the per-user rate limit is exhausted", async () => {
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: false,
+ remaining: 0,
+ resetAt: Date.now() + 60_000,
+ });
+
+ const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
+ expect(res.status).toBe(429);
+ expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith(
+ "medication-compliance:user-1",
+ 60,
+ 60_000,
+ );
+ // The limited request never touches the medication tables.
+ expect(prisma.medication.findUnique).not.toHaveBeenCalled();
+ });
+
+ it("reads through the per-user compliance cache key", async () => {
+ mockDailyMed();
+
+ const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
+ expect(res.status).toBe(200);
+
+ expect(vi.mocked(cached)).toHaveBeenCalledTimes(1);
+ const [, key] = vi.mocked(cached).mock.calls[0];
+ // `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
+ expect(key).toBe("user-1|med-1|compliance");
+ });
+
+ it("bounds the intake-event read to a trailing window and the consumed columns", async () => {
+ mockDailyMed();
+
+ const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
+ expect(res.status).toBe(200);
+
+ const call = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
+ .calls[0][0] as {
+ where: { scheduledFor?: { gte?: Date } };
+ select?: Record;
+ };
+ // Lower-bounded: the widest served window is 365 days (+1 day margin).
+ const gte = call.where.scheduledFor?.gte;
+ expect(gte).toBeInstanceOf(Date);
+ const minExpected = Date.now() - 367 * 24 * 60 * 60 * 1000;
+ expect((gte as Date).getTime()).toBeGreaterThan(minExpected);
+ // Column-bounded: only the five fields the math consumes
+ // (v1.15.20 adds the slot-binding provenance for pinned takes).
+ expect(call.select).toEqual({
+ takenAt: true,
+ skipped: true,
+ scheduledFor: true,
+ autoMissed: true,
+ attributionSource: true,
+ });
+ });
+});
diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts
index 7f75efd1a..905bdd490 100644
--- a/src/app/api/medications/[id]/compliance/route.ts
+++ b/src/app/api/medications/[id]/compliance/route.ts
@@ -3,81 +3,37 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
- buildComplianceDisplay,
buildComplianceMedicationContext,
- calculateCompliance,
+ buildMedicationComplianceBundle,
lastNonSkippedTakenAt,
+ type ComplianceDisplay,
+ type ComplianceResult,
+ type DailyComplianceEntry,
} from "@/lib/analytics/compliance";
-import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
-import type { SlotBand } from "@/lib/medications/scheduling/attribution";
-import {
- buildBandsForSchedules,
- type BandMinterMedication,
-} from "@/lib/medications/scheduling/band-minter";
-import {
- reconstructDoseHistory,
- type DoseHistoryRow,
- type HistoryIntake,
-} from "@/lib/medications/scheduling/dose-history";
-import {
- type CanonicalSchedule,
- type RecurrenceContext,
-} from "@/lib/medications/scheduling/recurrence";
-import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
+import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
+import { checkRateLimit } from "@/lib/rate-limit";
import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
-type MedicationScheduleRow = {
- id: string;
- rrule: string | null;
- rollingIntervalDays: number | null;
- timesOfDay: string[];
- daysOfWeek: string | null;
- windowStart: string;
- windowEnd: string;
- reminderGraceMinutes: number | null;
- scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
- cyclicOnWeeks: number | null;
- cyclicOffWeeks: number | null;
- /** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */
- doseWindows: unknown;
-};
+const DAY_MS = 24 * 60 * 60 * 1000;
/**
- * v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the
- * band minter consumes. A legacy daily row carrying only `windowStart`
- * surfaces it as the single `timeOfDay` so the minter mints its daily band.
+ * Lower bound for the intake-event read. The widest window any served
+ * block needs is the 365-day display rung; one extra day absorbs the
+ * inclusive-boundary edge so the ledger's own 365-day clamp never sits
+ * on a half-fetched day.
*/
-function toCanonicalForBands(
- s: MedicationScheduleRow,
- oneShot: boolean,
-): CanonicalSchedule {
- const base: CanonicalSchedule = {
- id: s.id,
- rrule: s.rrule,
- rollingIntervalDays: s.rollingIntervalDays,
- timesOfDay: s.timesOfDay,
- daysOfWeek: s.daysOfWeek,
- windowStart: s.windowStart,
- windowEnd: s.windowEnd,
- reminderGraceMinutes: s.reminderGraceMinutes,
- scheduleType: s.scheduleType,
- cyclicOnWeeks: s.cyclicOnWeeks,
- cyclicOffWeeks: s.cyclicOffWeeks,
- doseWindows: normaliseDoseWindows(s.doseWindows),
- };
- if (
- base.timesOfDay.length === 0 &&
- base.rrule === null &&
- base.rollingIntervalDays === null &&
- base.scheduleType !== "PRN" &&
- !oneShot
- ) {
- return { ...base, timesOfDay: [base.windowStart] };
- }
- return base;
+const EVENT_FETCH_WINDOW_DAYS = 366;
+
+/** The cached response body (the exact public wire shape). */
+interface CompliancePayload {
+ compliance7: ComplianceResult;
+ compliance30: ComplianceResult;
+ dailyCompliance: Record;
+ complianceDisplay: ComplianceDisplay;
}
/**
@@ -134,11 +90,156 @@ function bucketLedgerRow(
}
}
+/**
+ * The full compliance computation for one medication. Lifted out of the
+ * handler so the server cache can wrap it: ONE bounded intake-event read
+ * (only the four columns the math consumes, floored at the 366-day fetch
+ * window) feeds ONE shared band-expansion pass
+ * (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks,
+ * the cadence-scaled display rows AND the 90-day heatmap — replacing the
+ * historical five-plus per-request expansions over an unbounded,
+ * all-columns event read.
+ */
+async function buildCompliancePayload(
+ medication: {
+ id: string;
+ createdAt: Date;
+ startsOn: Date | null;
+ endsOn: Date | null;
+ oneShot: boolean;
+ schedules: Parameters[1];
+ },
+ userId: string,
+ userTz: string,
+): Promise {
+ // v1.15.9 — pin a single `now` and thread it into every cadence
+ // computation so no block can straddle a day boundary on a slow request.
+ const now = new Date();
+ const createdAt = medication.createdAt;
+
+ const fetchFrom = new Date(
+ Math.max(
+ createdAt.getTime(),
+ now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS,
+ ),
+ );
+ const events = await prisma.medicationIntakeEvent.findMany({
+ // v1.7.0 sync — exclude tombstoned rows from the compliance read.
+ // Bounded to the 366-day fetch window: every served block is a suffix
+ // of the 365-day ledger window, so older rows can never change the
+ // response.
+ where: {
+ medicationId: medication.id,
+ userId,
+ deletedAt: null,
+ scheduledFor: { gte: fetchFrom },
+ },
+ orderBy: { scheduledFor: "desc" },
+ select: {
+ takenAt: true,
+ skipped: true,
+ scheduledFor: true,
+ // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
+ // miss, not a neutral skip.
+ autoMissed: true,
+ // v1.15.20 — pinned takes bind by anchor in the unified ledger so the
+ // % agrees with the history view on a "zugeordnet" dose (taken-late).
+ attributionSource: true,
+ },
+ });
+
+ // v1.7.0 SB-SCHED-2 — thread the medication context so the denominator
+ // routes through the canonical engine (RRULE / rolling / one-shot / PRN /
+ // cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the
+ // fetch window (rolling cadences re-anchor on it).
+ const lastIntakeAt = lastNonSkippedTakenAt(events);
+ const medicationContext = buildComplianceMedicationContext(
+ medication,
+ lastIntakeAt,
+ userTz,
+ );
+
+ // ONE shared expansion pass for every served block (the v1.15.20
+ // performance fix): bands + ledger + timeline are minted once over the
+ // widest window and each sub-window is a tally over the same rows.
+ const bundle = buildMedicationComplianceBundle(
+ events,
+ medication.schedules,
+ medicationContext,
+ now,
+ );
+
+ // v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE
+ // unified dose-history ledger (the same bands the % + the history view
+ // read), so the per-day timing split (on-time / late) and the per-day
+ // missed marks can never disagree with the headline rate. The 90-day
+ // window is carved out of the shared ledger by `row.at`; the window
+ // floor clamps to the medication's creation so pre-existence days never
+ // mint phantom slots.
+ const heatmapFrom = new Date(
+ Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
+ );
+
+ const dailyCompliance: Record = {};
+ const byDay = new Map();
+ const ensureDay = (key: string): DailyComplianceEntry => {
+ let entry = byDay.get(key);
+ if (!entry) {
+ entry = {
+ expected: 0,
+ expectedCount: 0,
+ due: false,
+ taken: 0,
+ skipped: 0,
+ onTime: 0,
+ late: 0,
+ veryLate: 0,
+ early: 0,
+ };
+ byDay.set(key, entry);
+ }
+ return entry;
+ };
+
+ // Bucket each ledger row into its user-tz day. A slot row buckets on its
+ // anchor; an ad-hoc row on its real take time.
+ for (const row of bundle.ledgerRows) {
+ if (row.at.getTime() < heatmapFrom.getTime()) continue;
+ const key = userDayKey(row.at, userTz);
+ const entry = ensureDay(key);
+ bucketLedgerRow(entry, row);
+ }
+
+ for (const [key, entry] of byDay) {
+ entry.due = entry.expectedCount > 0;
+ dailyCompliance[key] = entry;
+ }
+
+ return {
+ compliance7: bundle.compliance7,
+ compliance30: bundle.compliance30,
+ dailyCompliance,
+ complianceDisplay: bundle.complianceDisplay,
+ };
+}
+
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
+
+ // The medications list fans this endpoint out once per card, so the
+ // per-user budget is generous — it only caps a runaway client loop.
+ const rl = await checkRateLimit(
+ `medication-compliance:${user.id}`,
+ 60,
+ 60_000,
+ );
+ if (!rl.allowed) {
+ return apiError("Too many compliance requests. Please retry later.", 429);
+ }
+
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
@@ -152,167 +253,19 @@ export const GET = apiHandler(
return apiError("Medication not found", 404);
}
- const events = await prisma.medicationIntakeEvent.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the compliance read.
- where: { medicationId: id, userId: user.id, deletedAt: null },
- orderBy: { scheduledFor: "desc" },
- });
-
- const mapped = events.map((e) => ({
- takenAt: e.takenAt,
- skipped: e.skipped,
- scheduledFor: e.scheduledFor,
- // v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
- // miss, not a neutral skip.
- autoMissed: e.autoMissed,
- }));
-
- const createdAt = medication.createdAt;
-
- // v1.7.0 SB-SCHED-2 — thread the medication context so the
- // denominator routes through the canonical engine (RRULE / rolling /
- // one-shot / PRN / cyclic) instead of the legacy daysOfWeek walker.
- // `lastIntakeAt` is the latest non-skipped takenAt (rolling cadences
- // re-anchor on it); the events list is already ordered scheduledFor
- // desc, so scan for the max takenAt.
- const lastIntakeAt = lastNonSkippedTakenAt(mapped);
const userTz = user.timezone || "Europe/Berlin";
- const medicationContext = buildComplianceMedicationContext(
- medication,
- lastIntakeAt,
- userTz,
+
+ // Read-through the per-user compliance cache (15 min TTL). Every
+ // intake / medication write flushes the `${userId}|` prefix via
+ // `invalidateUserMedications`, so a warm entry can only be stale
+ // relative to wall-clock drift, never to a user action.
+ const payload = await cached(
+ caches.medicationCompliance as ServerCache,
+ `${user.id}|${id}|compliance`,
+ () => buildCompliancePayload(medication, user.id, userTz),
+ annotate,
);
- // v1.15.9 — pin a single `now` and thread it into every cadence
- // computation. The two `calculateCompliance` calls + the display windows
- // can otherwise straddle a day boundary on a slow request, so a dose
- // would count in one window and not the next within the same response.
- const now = new Date();
-
- const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, {
- now,
- medicationContext,
- });
- const compliance30 = calculateCompliance(
- mapped,
- medication.schedules,
- 30,
- createdAt,
- { now, medicationContext },
- );
-
- // v1.8.6 — the two-row compliance display. The card always shows two
- // percentage rows; the server scales the two windows to the dosing
- // cadence (dense meds keep 7 / 30 days, sparse meds step both windows
- // up) and computes each row's rate over the chosen span. `compliance7`
- // / `compliance30` above are untouched — iOS + the Health Score read
- // them verbatim.
- const complianceDisplay = buildComplianceDisplay(
- mapped,
- medication.schedules,
- medicationContext,
- { now },
- );
-
- // v1.15.18 — the heatmap / line-chart daily map is now bucketed from the
- // ONE unified dose-history ledger (the same bands the % + the history view
- // read), so the per-day timing split (on-time / late) and the per-day
- // missed marks can never disagree with the headline rate. The legacy
- // per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount
- // ForDay` walk are retired here in favour of band membership: a take inside
- // a slot's on-time band reads on-time, inside its late tail reads late,
- // outside every band reads ad-hoc; an unfilled slot past its miss cutoff
- // reads missed; a future slot is upcoming (not yet due).
- const dailyCompliance: Record = {};
-
- // Build the bands across the whole 90-day heatmap window once. The window
- // floor clamps to the medication's creation so pre-existence days never
- // mint phantom slots.
- const DAY_MS = 24 * 60 * 60 * 1000;
- const windowFrom = new Date(
- Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
- );
- const bandMedication: BandMinterMedication = {
- id: medication.id,
- startsOn: medication.startsOn,
- endsOn: medication.endsOn,
- oneShot: medication.oneShot,
- createdAt: medication.createdAt,
- };
- const bandCtx: RecurrenceContext = {
- medication: {
- id: medication.id,
- startsOn: medication.startsOn,
- endsOn: medication.endsOn,
- oneShot: medication.oneShot,
- createdAt: medication.createdAt,
- },
- timeZone: userTz,
- lastIntakeAt,
- };
- const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map(
- (s) => toCanonicalForBands(s, medication.oneShot),
- );
- const intakeInstants = mapped
- .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now)
- .map((e) => e.takenAt as Date)
- .sort((a, b) => a.getTime() - b.getTime());
- const groups = buildBandsForSchedules({
- medication: bandMedication,
- schedules: canonicalSchedules,
- ctx: bandCtx,
- userTz,
- range: { from: windowFrom, to: now },
- now,
- intakeInstants,
- });
- const bands: SlotBand[] = [];
- for (const g of groups) {
- if (g.hasExpectedSlots) bands.push(...g.bands);
- }
- const historyIntakes: HistoryIntake[] = mapped
- .filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now)
- .map((e) => ({
- scheduledFor: e.scheduledFor,
- takenAt: e.takenAt,
- skipped: e.skipped,
- autoMissed: e.autoMissed,
- }));
- const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now);
-
- // Bucket each ledger row into its user-tz day. A slot row buckets on its
- // anchor; an ad-hoc row on its real take time.
- const byDay = new Map();
- const ensureDay = (key: string): DailyComplianceEntry => {
- let entry = byDay.get(key);
- if (!entry) {
- entry = {
- expected: 0,
- expectedCount: 0,
- due: false,
- taken: 0,
- skipped: 0,
- onTime: 0,
- late: 0,
- veryLate: 0,
- early: 0,
- };
- byDay.set(key, entry);
- }
- return entry;
- };
-
- for (const row of ledgerRows) {
- const key = userDayKey(row.at, userTz);
- const entry = ensureDay(key);
- bucketLedgerRow(entry, row);
- }
-
- for (const [key, entry] of byDay) {
- entry.due = entry.expectedCount > 0;
- dailyCompliance[key] = entry;
- }
-
annotate({
action: {
name: "medication.compliance",
@@ -320,18 +273,13 @@ export const GET = apiHandler(
entity_id: id,
},
meta: {
- compliance7: compliance7.rate,
- compliance30: compliance30.rate,
- complianceShortDays: complianceDisplay.shortDays,
- complianceLongDays: complianceDisplay.longDays,
+ compliance7: payload.compliance7.rate,
+ compliance30: payload.compliance30.rate,
+ complianceShortDays: payload.complianceDisplay.shortDays,
+ complianceLongDays: payload.complianceDisplay.longDays,
},
});
- return apiSuccess({
- compliance7,
- compliance30,
- dailyCompliance,
- complianceDisplay,
- });
+ return apiSuccess(payload);
},
);
diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts
index 9045525ea..11c47b7a2 100644
--- a/src/app/api/medications/[id]/dose-history/route.ts
+++ b/src/app/api/medications/[id]/dose-history/route.ts
@@ -77,6 +77,12 @@ interface SerializedDoseHistoryRow {
at: string;
timeOfDay: string | null;
status: DoseHistoryRow["status"];
+ /** v1.15.20 — slot served by a deliberate user pin ("zugeordnet"). */
+ pinned?: boolean;
+ /** v1.15.20 — due-context for an ad-hoc take: the nearest slot it could
+ * belong to (preferring an unserved one), so the UI can show "fällig
+ * gewesen" and offer the pin when `filled` is false. */
+ nearestSlot?: { at: string; timeOfDay: string; filled: boolean };
intake: {
id: string | null;
scheduledFor: string;
@@ -148,6 +154,8 @@ export const GET = apiHandler(
takenAt: true,
skipped: true,
autoMissed: true,
+ // v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
+ attributionSource: true,
},
});
@@ -157,6 +165,7 @@ export const GET = apiHandler(
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
+ pinned: e.attributionSource === "USER_PIN",
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
@@ -226,6 +235,7 @@ export const GET = apiHandler(
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
+ pinned: e.pinned,
}));
const rows = reconstructDoseHistory(bands, historyIntakes, now);
@@ -235,6 +245,14 @@ export const GET = apiHandler(
at: row.at.toISOString(),
timeOfDay: row.timeOfDay,
status: row.status,
+ ...(row.pinned && { pinned: true }),
+ ...(row.nearestSlot && {
+ nearestSlot: {
+ at: row.nearestSlot.at.toISOString(),
+ timeOfDay: row.nearestSlot.timeOfDay,
+ filled: row.nearestSlot.filled,
+ },
+ }),
intake: row.intake
? {
id: row.intake.id ?? null,
diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
index cde47a0cd..5a2e2751b 100644
--- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
+++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts
@@ -358,6 +358,50 @@ describe("PUT — v1.15.18 band re-attribution", () => {
expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime());
});
+ it("unpin (forceSlotInstant: null) keeps USER_PIN provenance on the released ad-hoc row (v1.16.0)", async () => {
+ // The row is pinned onto the 07:00 anchor; its real takenAt (12:00) is
+ // outside every band. Releasing the binding re-attributes by band →
+ // miss → the row re-anchors on its own takenAt — and the release is
+ // itself a user-fixed decision, so the row must keep USER_PIN (the
+ // nightly dedup keys its standalone guarantee on that marker).
+ vi.mocked(prisma.medicationIntakeEvent.findFirst)
+ // PUT lookup of the edited event
+ .mockResolvedValueOnce({
+ id: "e1",
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: at(7, 0),
+ takenAt: at(12, 0),
+ skipped: false,
+ } as never)
+ // lifecycle liveIntake probe
+ .mockResolvedValue(null as never);
+ // applyCanonicalSlotWrite: no existing row at the 12:00 instant → create.
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+ [] as never,
+ );
+ vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
+ {} as never,
+ );
+ vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
+ id: "e2",
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: at(12, 0),
+ takenAt: at(12, 0),
+ skipped: false,
+ } as never);
+
+ const res = await PUT(putReq({ forceSlotInstant: null }), ROUTE_CTX);
+ expect(res.status).toBe(200);
+
+ const created = vi.mocked(prisma.medicationIntakeEvent.create).mock
+ .calls[0]?.[0]?.data;
+ // Released row: anchored on its own takenAt, provenance stays USER_PIN.
+ expect((created?.scheduledFor as Date).getTime()).toBe(at(12, 0).getTime());
+ expect(created?.attributionSource).toBe("USER_PIN");
+ });
+
it("422s an edited takenAt before the medication's start date (P0-4)", async () => {
// Use fixed wall-clock so the schema's "not in the future / within 5
// years" bounds hold regardless of when the suite runs.
@@ -458,6 +502,48 @@ describe("PUT — v1.15.18 band re-attribution", () => {
}
});
+ it("422s a pin onto a slot already served by another recorded action (v1.16.0)", async () => {
+ // The edited event is an ad-hoc take; the pin targets the real 07:00
+ // slot — but that slot already carries a DIFFERENT recorded take.
+ // Converging would overwrite it (last-write-wins), so the route
+ // refuses with `medications.intake.force_slot.occupied` before any
+ // write happens.
+ vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
+ id: "e1",
+ userId: "user-1",
+ medicationId: "m1",
+ scheduledFor: at(12, 0),
+ takenAt: at(12, 0),
+ skipped: false,
+ } as never);
+ // findPinConflict's slot-row read: the 07:00 anchor is served.
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
+ {
+ id: "other-take",
+ takenAt: at(7, 5),
+ skipped: false,
+ idempotencyKey: null,
+ scheduledFor: at(7, 0),
+ source: "WEB",
+ createdAt: at(0, 1),
+ },
+ ] as never);
+
+ const res = await PUT(
+ putReq({ forceSlotInstant: at(7, 0).toISOString() }),
+ ROUTE_CTX,
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as {
+ meta?: { errorCode?: string };
+ };
+ expect(body.meta?.errorCode).toBe(
+ "medications.intake.force_slot.occupied",
+ );
+ expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
+ expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
+ });
+
it("422s a forceSlotInstant that is not a real slot", async () => {
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts
index 22996a426..b8896c325 100644
--- a/src/app/api/medications/[id]/intake/[eventId]/route.ts
+++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts
@@ -17,6 +17,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import {
applyCanonicalSlotWrite,
+ findPinConflict,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
@@ -124,13 +125,28 @@ export const PUT = apiHandler(
// leaving `scheduledFor` stale (audit HIGH-4). Precedence:
// 1. an explicit `scheduledFor` in the body wins (deliberate override);
// 2. else `forceSlotInstant` pins onto a named real slot (422 if it is
- // not a slot of this med);
- // 3. else a taken edit attributes by band (slot anchor on match, the
- // take's own time on a miss → ad-hoc);
- // 4. a skip / pending edit keeps the existing `scheduledFor` anchor.
+ // not a slot of this med) — v1.15.20: standalone too, no takenAt /
+ // skipped change required ("Slot zuordnen" from the ledger kebab),
+ // and the row is stamped USER_PIN so the read ledger binds it by
+ // anchor instead of degrading the off-window take back to ad-hoc;
+ // 3. else an explicit `forceSlotInstant: null` UNPINS (v1.15.20,
+ // "Zuordnung lösen"): re-attribute the take by band on its takenAt
+ // (ad-hoc when no band matches). v1.16.0 — the release is itself a
+ // deliberate binding decision, so the row KEEPS `USER_PIN`
+ // provenance ("attribution user-fixed": onto a slot OR deliberately
+ // ad-hoc). The persisted marker is what keeps the nightly slot
+ // dedup from snapping the released row back into the cluster the
+ // user just detached it from;
+ // 4. else a taken edit attributes by band (slot anchor on match, the
+ // take's own time on a miss → ad-hoc), provenance AUTO;
+ // 5. a skip / pending edit keeps the existing `scheduledFor` anchor.
let resolvedScheduledFor: Date | undefined = data.scheduledFor;
- if (resolvedScheduledFor === undefined && takenOrSkippedChanged) {
- if (data.forceSlotInstant !== undefined) {
+ let attributionSource: "AUTO" | "USER_PIN" | undefined;
+ if (resolvedScheduledFor === undefined) {
+ if (
+ data.forceSlotInstant !== undefined &&
+ data.forceSlotInstant !== null
+ ) {
const forced = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: id,
@@ -148,8 +164,37 @@ export const PUT = apiHandler(
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
+ // v1.16.0 — refuse to pin onto a slot another recorded action
+ // already serves (excluding the row being edited): the explicit-
+ // write last-write-wins rule would silently overwrite that dose
+ // record. The ledger UI only offers the pin for unserved slots,
+ // so this only fires for stale clients / raw API calls.
+ if (
+ await findPinConflict({
+ userId: user.id,
+ medicationId: id,
+ canonicalSlot: forced,
+ incomingTakenAt: nextSkipped ? null : nextTakenAt,
+ excludeEventId: eventId,
+ })
+ ) {
+ annotate({
+ action: { name: "medication.intake.force_slot.occupied" },
+ meta: { medication_id: id, event_id: eventId },
+ });
+ return apiError(
+ "forceSlotInstant already carries a recorded dose action",
+ 422,
+ { errorCode: "medications.intake.force_slot.occupied" },
+ );
+ }
resolvedScheduledFor = forced;
- } else if (nextTakenAt !== null && !nextSkipped) {
+ attributionSource = "USER_PIN";
+ } else if (
+ (data.forceSlotInstant === null || takenOrSkippedChanged) &&
+ nextTakenAt !== null &&
+ !nextSkipped
+ ) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
@@ -158,6 +203,16 @@ export const PUT = apiHandler(
});
// band.at on match; the take's own time on a miss (ad-hoc).
resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt;
+ // v1.16.0 — an explicit unpin stays user-fixed (USER_PIN) even on
+ // the released row; a plain taken/skipped edit re-attributes AUTO.
+ attributionSource =
+ data.forceSlotInstant === null ? "USER_PIN" : "AUTO";
+ } else if (data.forceSlotInstant === null) {
+ // Unpin on a row that is not a confirmed take (skip / pending):
+ // nothing to re-attribute, but the provenance still resets — a
+ // skip / pending row anchors on its slot by construction, so there
+ // is no user-fixed ad-hoc decision to persist.
+ attributionSource = "AUTO";
}
// skip / pending edits leave `scheduledFor` on the existing anchor.
}
@@ -175,6 +230,11 @@ export const PUT = apiHandler(
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
+ // v1.15.20 — binding provenance: pin / unpin / band re-attribution
+ // stamps it even when the anchor itself did not move (e.g. a pin
+ // onto the slot the row already sits on, or an unpin whose band
+ // re-attribution lands on the same anchor).
+ ...(attributionSource !== undefined && { attributionSource }),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
@@ -203,6 +263,7 @@ export const PUT = apiHandler(
isExplicitSkip: nextSkipped,
idempotencyKey: null,
createSource: "WEB",
+ attributionSource,
});
updated = applied.row;
}
diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts
index 0cff38b3f..9765c07ac 100644
--- a/src/app/api/medications/[id]/intake/import/route.ts
+++ b/src/app/api/medications/[id]/intake/import/route.ts
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
@@ -158,6 +159,13 @@ export const POST = apiHandler(
}
}
+ // v1.15.20 — imported intakes change every cached medication read
+ // (list, intake caches, compliance payload). This write path was the
+ // one intake surface missing the flush.
+ if (imported > 0) {
+ invalidateUserMedications(user.id);
+ }
+
await auditLog("medication.intake.import", {
userId: user.id,
ipAddress: getClientIp(request),
diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts
index e201ebf22..eaa82370f 100644
--- a/src/app/api/medications/[id]/intake/route.ts
+++ b/src/app/api/medications/[id]/intake/route.ts
@@ -24,6 +24,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
+ findPinConflict,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
@@ -167,6 +168,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// `forceSlotInstant` pins an off-window take onto a chosen real slot
// ("diesem Slot zuordnen?"); a pin that is not a real slot is a 422.
let canonicalSlot: Date | null = null;
+ // v1.15.20 — binding provenance for the written row: USER_PIN on the
+ // forced "diesem Slot zuordnen" path, AUTO when band attribution decided.
+ // Skips carry no binding decision (undefined → column untouched/default).
+ let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (skipped) {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
@@ -194,6 +199,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
+ // v1.16.0 — refuse to pin onto a slot another recorded action already
+ // serves: the explicit-write last-write-wins rule would silently
+ // overwrite that dose record. The ledger UI only offers the pin for
+ // unserved slots, so this only fires for stale clients / raw API calls.
+ if (
+ await findPinConflict({
+ userId: user.id,
+ medicationId: id,
+ canonicalSlot,
+ incomingTakenAt: resolvedTakenAt ?? null,
+ })
+ ) {
+ annotate({
+ action: { name: "medication.intake.force_slot.occupied" },
+ meta: { medication_id: id },
+ });
+ return apiError(
+ "forceSlotInstant already carries a recorded dose action",
+ 422,
+ { errorCode: "medications.intake.force_slot.occupied" },
+ );
+ }
+ attributionSource = "USER_PIN";
} else {
// A non-skip write on this route always carries a `takenAt` (defaulted to
// now), so `resolvedTakenAt` is non-null here; the fallback only guards the
@@ -205,6 +233,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
takenAt: resolvedTakenAt ?? incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
+ // A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
+ // stale USER_PIN when this write converges onto a previously-pinned row.
+ attributionSource = "AUTO";
}
// Idempotency check (explicit key or server-side dedup window)
@@ -263,6 +294,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// v1.8.5 — resolved + validated site (null unless a tracking-on
// injection taken write supplied an allowed site).
injectionSite: resolvedInjectionSite,
+ attributionSource,
});
event = applied.row;
consumedTransition = applied.consumedTransition;
@@ -309,6 +341,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
idempotencyKey: idempotencyKey ?? null,
createSource: "WEB",
injectionSite: resolvedInjectionSite,
+ attributionSource,
});
event = applied.row;
consumedTransition = applied.consumedTransition;
diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts
index c200acf77..eb2df8097 100644
--- a/src/app/api/medications/[id]/route.ts
+++ b/src/app/api/medications/[id]/route.ts
@@ -19,10 +19,49 @@ import {
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import { getUserTodayBounds } from "@/lib/timezone";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
+/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
+function hhmmToMinutes(value: string): number {
+ const [h, m] = value.split(":").map(Number);
+ if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
+ return h * 60 + m;
+}
+
+/**
+ * Reconcile a schedule's `windowStart` / `windowEnd` with its effective
+ * `timesOfDay`. The schedule editor can change only the dose times while the
+ * client echoes the previous window back; a time outside the window then
+ * breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]`
+ * (the window-status pill, the reminder phases, the legacy compliance
+ * walker). When ANY time falls outside the window — including the overnight
+ * `windowEnd < windowStart` shape, checked with wrap-around membership —
+ * the window is pulled to the min/max of the times so the persisted row is
+ * self-consistent. A window that already covers every time is left
+ * byte-identical (explicitly wider windows are a feature).
+ */
+function reconcileScheduleWindow(
+ windowStart: string,
+ windowEnd: string,
+ timesOfDay: string[],
+): { windowStart: string; windowEnd: string } {
+ if (timesOfDay.length === 0) return { windowStart, windowEnd };
+ const startMins = hhmmToMinutes(windowStart);
+ const endMins = hhmmToMinutes(windowEnd);
+ const overnight = endMins < startMins;
+ const inWindow = (t: number): boolean =>
+ overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins;
+ const sorted = [...timesOfDay].sort(
+ (a, b) => hhmmToMinutes(a) - hhmmToMinutes(b),
+ );
+ const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t)));
+ if (allInside) return { windowStart, windowEnd };
+ return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] };
+}
+
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -239,6 +278,31 @@ export const PUT = apiHandler(
// If schedules provided, replace all
if (schedules) {
+ // A schedule replace invalidates the open slot anchors the projector /
+ // reminder worker minted for the OLD times: a pending 08:00 row for a
+ // medication that now doses at 20:00 would linger as a phantom slot,
+ // auto-miss later, and depress compliance. Tombstone today's and
+ // future open pending rows (never an actioned row — `takenAt`,
+ // `skipped`, and `autoMissed` are user-visible history) and bump
+ // `syncVersion` so delta-sync clients drop them. The projector and
+ // the reminder worker re-mint the anchors for the new times on their
+ // next pass — the route does not pre-create them.
+ const { start: todayStart } = getUserTodayBounds(
+ new Date(),
+ user.timezone || "Europe/Berlin",
+ );
+ await prisma.medicationIntakeEvent.updateMany({
+ where: {
+ userId: user.id,
+ medicationId: id,
+ deletedAt: null,
+ takenAt: null,
+ skipped: false,
+ autoMissed: false,
+ scheduledFor: { gte: todayStart },
+ },
+ data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
+ });
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});
@@ -292,9 +356,19 @@ export const PUT = apiHandler(
? s.timesOfDay
: [s.windowStart];
+ // Invariant 4 — window / times consistency. See
+ // `reconcileScheduleWindow`: a times-only edit that echoes the
+ // stale window back gets the window pulled to the min/max of
+ // the new times.
+ const window = reconcileScheduleWindow(
+ s.windowStart,
+ s.windowEnd,
+ effectiveTimesOfDay,
+ );
+
return {
- windowStart: s.windowStart,
- windowEnd: s.windowEnd,
+ windowStart: window.windowStart,
+ windowEnd: window.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts
index 5da4e8ce8..d33694a57 100644
--- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts
+++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts
@@ -66,7 +66,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
- user: { id: "user-1", username: "tester", role: "USER" as const },
+ // Explicit timezone keeps the band/slot fixtures below host-TZ-stable:
+ // the engine expands occurrences in the user's zone, so an unset value
+ // would silently track the machine's clock.
+ user: {
+ id: "user-1",
+ username: "tester",
+ role: "USER" as const,
+ timezone: "Europe/Berlin",
+ },
};
function postReq(body: unknown): NextRequest {
@@ -480,6 +488,45 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
);
});
+ it("anchors a pending echo through the canonical snap (band engine is taken-only)", async () => {
+ // A pending echo (no takenAt) has nothing to attribute by — it must
+ // keep the anchor snap that binds it to the projector-minted slot row.
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
+ {
+ id: "row-pending",
+ takenAt: null,
+ skipped: false,
+ idempotencyKey: null,
+ scheduledFor: new Date("2026-06-15T05:00:00Z"),
+ source: "REMINDER",
+ createdAt: new Date("2026-06-15T00:00:00Z"),
+ },
+ ] as never);
+ vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
+ id: "row-pending",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ scheduledFor: "2026-06-15T05:00:30.000Z",
+ // no takenAt → pending echo onto the (still pending) slot row
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { updated: number; entries: Array<{ status: string }> };
+ };
+ // The echo converges onto the canonical slot row (here a no-op update
+ // path through the shared upsert), never a standalone insert.
+ expect(body.data.entries[0].status).toBe("updated");
+ expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
+ });
+
it("keeps the idempotencyKey replay contract: a re-submission reports duplicate, not updated", async () => {
// The replay pre-check fires BEFORE the convergence probe so a re-sent
// entry keeps its historical `duplicate` status.
@@ -513,3 +560,238 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
});
});
+
+// ────────────────────────────────────────────────────────────────────
+// v1.15.20 — taken writes attribute through the window-band engine
+// ────────────────────────────────────────────────────────────────────
+
+describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () => {
+ const SCHEDULED_MED = {
+ id: "med-1",
+ startsOn: null,
+ endsOn: null,
+ oneShot: false,
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ schedules: [
+ {
+ id: "s1",
+ windowStart: "07:00",
+ windowEnd: "07:00",
+ daysOfWeek: null,
+ timesOfDay: ["07:00", "19:00"],
+ reminderGraceMinutes: null,
+ rrule: null,
+ rollingIntervalDays: null,
+ scheduleType: "SCHEDULED",
+ cyclicOnWeeks: null,
+ cyclicOffWeeks: null,
+ doseWindows: null,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ // 20:30 Berlin on 2026-06-15 — past both fixture slots for the day.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-06-15T18:30:00.000Z"));
+ vi.mocked(prisma.medication.findMany).mockResolvedValue([
+ { id: "med-1" },
+ ] as never);
+ vi.mocked(prisma.medication.findFirst).mockResolvedValue(
+ SCHEDULED_MED as never,
+ );
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("attributes a takenAt-only entry to its slot by band membership", async () => {
+ // 19:30 Berlin sits inside the 19:00 slot's ±60 min on-time band — the
+ // take must converge onto the slot's pending REMINDER row even though
+ // the client named no scheduledFor (the legacy snap refused to bind a
+ // defaulted anchor to a slot; the band engine binds by the take itself).
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
+ {
+ id: "row-evening",
+ takenAt: null,
+ skipped: false,
+ idempotencyKey: null,
+ scheduledFor: new Date("2026-06-15T17:00:00Z"),
+ source: "REMINDER",
+ createdAt: new Date("2026-06-15T00:00:00Z"),
+ },
+ ] as never);
+ vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
+ id: "row-evening",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ takenAt: "2026-06-15T17:30:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: {
+ updated: number;
+ inserted: number;
+ entries: Array<{ status: string; id?: string }>;
+ };
+ };
+ expect(body.data.updated).toBe(1);
+ expect(body.data.inserted).toBe(0);
+ expect(body.data.entries[0].status).toBe("updated");
+ expect(body.data.entries[0].id).toBe("row-evening");
+ expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
+ });
+
+ it("forceSlotInstant pins an off-window take onto the named real slot", async () => {
+ // 14:00 Berlin falls in no band; the client pins the take onto the
+ // morning slot anchor (05:00Z = 07:00 Berlin). The slot rows are read
+ // twice on the pin path: once by the v1.16.0 occupied-slot guard
+ // (`findPinConflict` — a pending row is not a conflict), once by the
+ // canonical upsert itself.
+ const morningPendingRow = {
+ id: "row-morning",
+ takenAt: null,
+ skipped: false,
+ idempotencyKey: null,
+ scheduledFor: new Date("2026-06-15T05:00:00Z"),
+ source: "REMINDER",
+ createdAt: new Date("2026-06-15T00:00:00Z"),
+ };
+ vi.mocked(prisma.medicationIntakeEvent.findMany)
+ .mockResolvedValueOnce([morningPendingRow] as never)
+ .mockResolvedValueOnce([morningPendingRow] as never);
+ vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
+ id: "row-morning",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ takenAt: "2026-06-15T12:00:00.000Z",
+ forceSlotInstant: "2026-06-15T05:00:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { updated: number; entries: Array<{ status: string; id?: string }> };
+ };
+ expect(body.data.updated).toBe(1);
+ expect(body.data.entries[0].status).toBe("updated");
+ expect(body.data.entries[0].id).toBe("row-morning");
+ });
+
+ it("marks an entry skipped when the pinned slot already carries a recorded action", async () => {
+ // v1.16.0 — the morning slot is already served by a different take;
+ // pinning another take onto it would overwrite that dose record via
+ // last-write-wins, so the entry is refused per-entry
+ // (`force_slot_occupied`) instead of converging.
+ vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
+ {
+ id: "row-morning",
+ takenAt: new Date("2026-06-15T05:10:00Z"),
+ skipped: false,
+ idempotencyKey: null,
+ scheduledFor: new Date("2026-06-15T05:00:00Z"),
+ source: "WEB",
+ createdAt: new Date("2026-06-15T00:00:00Z"),
+ },
+ ] as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ takenAt: "2026-06-15T12:00:00.000Z",
+ forceSlotInstant: "2026-06-15T05:00:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: {
+ skipped: Array<{ index: number; reason: string }>;
+ entries: Array<{ status: string; reason?: string }>;
+ };
+ };
+ expect(body.data.entries[0].status).toBe("skipped");
+ expect(body.data.entries[0].reason).toBe("force_slot_occupied");
+ expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
+ expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
+ });
+
+ it("marks an entry skipped when forceSlotInstant names no real slot", async () => {
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ takenAt: "2026-06-15T12:00:00.000Z",
+ forceSlotInstant: "2026-06-15T12:34:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: {
+ skipped: Array<{ index: number; reason: string }>;
+ entries: Array<{ status: string; reason?: string }>;
+ };
+ };
+ expect(body.data.entries[0].status).toBe("skipped");
+ expect(body.data.entries[0].reason).toBe("force_slot_invalid");
+ expect(body.data.skipped[0]).toEqual({
+ index: 0,
+ reason: "force_slot_invalid",
+ });
+ expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
+ expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
+ });
+
+ it("files an out-of-band take as ad-hoc (scheduledFor = takenAt)", async () => {
+ // 14:00 Berlin, no band, no pin → standalone row anchored on the take.
+ vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
+ id: "row-adhoc",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ medicationId: "med-1",
+ takenAt: "2026-06-15T12:00:00.000Z",
+ },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: { inserted: number; entries: Array<{ status: string }> };
+ };
+ expect(body.data.inserted).toBe(1);
+ expect(body.data.entries[0].status).toBe("inserted");
+ expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ scheduledFor: new Date("2026-06-15T12:00:00.000Z"),
+ takenAt: new Date("2026-06-15T12:00:00.000Z"),
+ }),
+ }),
+ );
+ });
+});
diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts
index 2bdbd06cd..13c5eb342 100644
--- a/src/app/api/medications/intake/bulk/route.ts
+++ b/src/app/api/medications/intake/bulk/route.ts
@@ -17,6 +17,9 @@
* idempotencyKey?, — Medication.intake_events.idempotency_key
* (existing UNIQUE column; serves as the
* per-entry dedup hint)
+ * forceSlotInstant?, — ISO timestamp; pins a taken entry onto a
+ * named real scheduled slot (server-validated
+ * against the band anchors)
* }
*
* Response (always 200): processed / inserted / duplicates / skipped /
@@ -49,6 +52,9 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
+ findPinConflict,
+ resolveForcedSlotForWrite,
+ resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
@@ -79,6 +85,17 @@ const bulkEntrySchema = z.object({
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
+ // v1.15.20 — late-take "attribute anyway" pin, mirroring the single
+ // intake route: pin a taken entry onto a named real scheduled slot
+ // instead of the default window-band attribution. Validated server-side
+ // against the band anchors; an instant that is not a slot marks the
+ // entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
+ // Ignored on non-taken entries (a pending echo / skip carries no take
+ // to attribute).
+ forceSlotInstant: z.iso
+ .datetime({ offset: true })
+ .transform((s) => new Date(s))
+ .optional(),
});
const bulkPayloadSchema = z.object({
@@ -228,32 +245,6 @@ async function postBulk(request: NextRequest): Promise {
const incomingScheduledFor =
entry.scheduledFor ?? entry.takenAt ?? new Date();
- // v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
- // reminder action here (source API); without this it inserted a
- // SECOND row for a slot already carrying the projector/worker's
- // pending REMINDER row (the unique key includes `source`, and the
- // iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
- // canonical slot instant and update the existing row in place.
- const canonicalSlot = await resolveSlotInstantForWrite({
- userId: user.id,
- medicationId: entry.medicationId,
- userTz: user.timezone,
- incoming: incomingScheduledFor,
- // Only a client-supplied `scheduledFor` names a real slot. A
- // `takenAt`-only / defaulted-now write must not snap across the
- // wide ±halfGap window onto a far slot (phantom morning dose).
- instantIsExplicit: entry.scheduledFor !== undefined,
- // Dose-safety: a taken write (has `takenAt`, not skipped) must never
- // snap forward onto a future slot. A pending sync echo (no `takenAt`)
- // legitimately maps to a future slot, so the guard stays off for it.
- isTakenWrite: !entry.skipped && entry.takenAt !== undefined,
- });
-
- // The `scheduledFor` instant the row actually lands on — set by the
- // branch that performs the write so the rollup recompute below keys
- // the same day the stored row anchors to.
- let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
-
// C2 — classify the incoming write. An offline-sync replay echoes a
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
// user may already have actioned; that echo must NEVER clear a
@@ -262,6 +253,90 @@ async function postBulk(request: NextRequest): Promise {
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
const isExplicitSkip = entry.skipped === true;
+ // v1.15.20 — taken writes attribute through the window-band engine
+ // (the SAME minter the read ledger + the compliance % consume), so a
+ // bulk-synced take binds to a slot identically to the single-route
+ // write. The optional `forceSlotInstant` pins an off-window take onto
+ // a named real slot. Pending echoes and skips carry no take to
+ // attribute by, so they keep the canonical anchor snap that binds
+ // them to the projector/worker-minted slot row.
+ let canonicalSlot: Date | null;
+ // v1.15.20 — binding provenance: USER_PIN on the forced path, AUTO on
+ // a band decision. Pending echoes / skips carry no decision
+ // (undefined → the upsert leaves an existing USER_PIN untouched).
+ let attributionSource: "AUTO" | "USER_PIN" | undefined;
+ if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
+ canonicalSlot = await resolveForcedSlotForWrite({
+ userId: user.id,
+ medicationId: entry.medicationId,
+ userTz: user.timezone,
+ slotInstant: entry.forceSlotInstant,
+ });
+ if (canonicalSlot === null) {
+ // Mirrors the single route's 422 (`medications.intake.force_slot
+ // .invalid`) as a per-entry skip so one bad pin never fails the
+ // batch.
+ const reason = "force_slot_invalid";
+ skipped.push({ index: i, reason });
+ results.push({ index: i, status: "skipped", reason });
+ continue;
+ }
+ // v1.16.0 — refuse to pin onto a slot another recorded action
+ // already serves: the explicit-write last-write-wins rule would
+ // silently overwrite that dose record. Per-entry skip, mirroring
+ // the single route's 422 `medications.intake.force_slot.occupied`.
+ if (
+ await findPinConflict({
+ userId: user.id,
+ medicationId: entry.medicationId,
+ canonicalSlot,
+ incomingTakenAt: entry.takenAt ?? null,
+ })
+ ) {
+ const reason = "force_slot_occupied";
+ skipped.push({ index: i, reason });
+ results.push({ index: i, status: "skipped", reason });
+ continue;
+ }
+ attributionSource = "USER_PIN";
+ } else if (isExplicitTaken) {
+ const attribution = await resolveSlotForWriteByBand({
+ userId: user.id,
+ medicationId: entry.medicationId,
+ userTz: user.timezone,
+ takenAt: entry.takenAt as Date,
+ });
+ canonicalSlot = attribution.slotInstant;
+ attributionSource = "AUTO";
+ } else {
+ // v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
+ // iOS posts the reminder actions here (source API); without this
+ // they inserted a SECOND row for a slot already carrying the
+ // projector/worker's pending REMINDER row (the unique key includes
+ // `source`, and the iOS-vs-server `scheduledFor` drifts by a
+ // minute). Snap onto the canonical slot instant and update the
+ // existing row in place.
+ canonicalSlot = await resolveSlotInstantForWrite({
+ userId: user.id,
+ medicationId: entry.medicationId,
+ userTz: user.timezone,
+ incoming: incomingScheduledFor,
+ // Only a client-supplied `scheduledFor` names a real slot. A
+ // defaulted-now write must not snap across the wide ±halfGap
+ // window onto a far slot (phantom morning dose).
+ instantIsExplicit: entry.scheduledFor !== undefined,
+ // Pending echoes / skips never record a take; a pending sync
+ // echo legitimately maps to a future slot, so the taken
+ // future-slot guard stays off here.
+ isTakenWrite: false,
+ });
+ }
+
+ // The `scheduledFor` instant the row actually lands on — set by the
+ // branch that performs the write so the rollup recompute below keys
+ // the same day the stored row anchors to.
+ let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
+
// v1.8.5 — resolve + validate the optional per-entry injection
// site. A disallowed site marks the entry skipped without failing
// the batch (per-entry contract); a non-injection / tracking-off /
@@ -305,6 +380,7 @@ async function postBulk(request: NextRequest): Promise {
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
injectionSite: resolvedInjectionSite,
+ attributionSource,
});
if (applied.noDowngradeNoOp) {
// C2 — pending echo onto an already-actioned slot. Report it as a
@@ -374,6 +450,7 @@ async function postBulk(request: NextRequest): Promise {
idempotencyKey: entry.idempotencyKey ?? null,
createSource: "API",
injectionSite: resolvedInjectionSite,
+ attributionSource,
});
if (applied.noDowngradeNoOp) {
duplicates += 1;
diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts
index 82b9c1b4b..e57697711 100644
--- a/src/app/api/user/profile/__tests__/route.test.ts
+++ b/src/app/api/user/profile/__tests__/route.test.ts
@@ -57,7 +57,7 @@ describe("GET /api/user/profile", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
- displayName: "Marc B.",
+ displayName: "Alex T.",
email: "marc@example.com",
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
gender: "MALE",
@@ -77,7 +77,7 @@ describe("GET /api/user/profile", () => {
};
};
expect(body.data.username).toBe("marc");
- expect(body.data.displayName).toBe("Marc B.");
+ expect(body.data.displayName).toBe("Alex T.");
expect(body.data.heightCm).toBe(180);
expect(body.data.locale).toBe("de");
});
@@ -137,7 +137,7 @@ describe("PATCH /api/user/profile", () => {
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
- displayName: "Marc B.",
+ displayName: "Alex T.",
email: null,
role: "USER",
heightCm: null,
@@ -147,13 +147,13 @@ describe("PATCH /api/user/profile", () => {
locale: "de",
} as never);
- const res = await PATCH(req({ displayName: "Marc B.", locale: "de" }));
+ const res = await PATCH(req({ displayName: "Alex T.", locale: "de" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
- displayName: "Marc B.",
+ displayName: "Alex T.",
locale: "de",
}),
}),
@@ -200,4 +200,50 @@ describe("PATCH /api/user/profile", () => {
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
+
+ // v1.15.20 — hour-cycle display preference rides the shared profile
+ // update path: a valid enum value persists field-by-field and echoes
+ // back; anything outside AUTO/H12/H24 is rejected by the Zod schema.
+ it("persists a valid timeFormat and echoes it back", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
+ vi.mocked(prisma.user.update).mockResolvedValue({
+ id: "user-1",
+ username: "marc",
+ displayName: null,
+ email: null,
+ role: "USER",
+ heightCm: null,
+ dateOfBirth: null,
+ gender: null,
+ timezone: "Europe/Berlin",
+ locale: null,
+ timeFormat: "H24",
+ moodReminderEnabled: false,
+ fullName: null,
+ insurerName: null,
+ insurerIkNumber: null,
+ insuranceNumberEncrypted: null,
+ } as never);
+
+ const res = await PATCH(req({ timeFormat: "H24" }));
+ expect(res.status).toBe(200);
+ expect(prisma.user.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: "user-1" },
+ data: expect.objectContaining({ timeFormat: "H24" }),
+ }),
+ );
+ const body = (await res.json()) as {
+ data: { timeFormat: string };
+ };
+ expect(body.data.timeFormat).toBe("H24");
+ });
+
+ it("returns 422 for an invalid timeFormat", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ const res = await PATCH(req({ timeFormat: "12h" }));
+ expect(res.status).toBe(422);
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts
index 1722858a5..cf799b456 100644
--- a/src/app/api/user/profile/route.ts
+++ b/src/app/api/user/profile/route.ts
@@ -36,6 +36,7 @@ export const GET = apiHandler(async () => {
heightCm: true,
locale: true,
timezone: true,
+ timeFormat: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
@@ -64,6 +65,7 @@ export const GET = apiHandler(async () => {
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
+ timeFormat: dbUser?.timeFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
fullName: dbUser?.fullName ?? null,
insurerName: dbUser?.insurerName ?? null,
@@ -96,6 +98,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
+ timeFormat: result.user.timeFormat,
moodReminderEnabled: result.user.moodReminderEnabled,
fullName: result.user.fullName,
insurerName: result.user.insurerName,
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
index af8958252..99bf541ed 100644
--- a/src/app/auth/login/page.tsx
+++ b/src/app/auth/login/page.tsx
@@ -151,9 +151,9 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "passkey" ? (
-
+
) : (
-
+
)}
{t("auth.loginWithPasskey")}
@@ -173,7 +173,7 @@ export default function LoginPage() {
size="lg"
onClick={() => setMode("password")}
>
-
+
{t("auth.loginWithPassword")}
) : (
@@ -220,9 +220,9 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "password" ? (
-
+
) : (
-
+
)}
{t("auth.login")}
diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx
index 73e56ac8c..417d5f5d3 100644
--- a/src/app/auth/register/page.tsx
+++ b/src/app/auth/register/page.tsx
@@ -1,9 +1,9 @@
"use client";
import { useId, useState } from "react";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, UserPlus } from "lucide-react";
+import { Loader2, MailOpen, UserPlus } from "lucide-react";
import { Logo } from "@/components/ui/logo";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -16,6 +16,11 @@ import { queryKeys } from "@/lib/query-keys";
export default function RegisterPage() {
const router = useRouter();
+ const searchParams = useSearchParams();
+ // v1.15.20 — invite deep link (`/auth/register?invite=hlv_…`). The
+ // token rides the signup POST so a closed-registration instance still
+ // admits the invited user; an open instance simply ignores it.
+ const inviteToken = searchParams.get("invite");
const queryClient = useQueryClient();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
@@ -45,7 +50,13 @@ export default function RegisterPage() {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, username, password, timezone }),
+ body: JSON.stringify({
+ email,
+ username,
+ password,
+ timezone,
+ ...(inviteToken ? { inviteToken } : {}),
+ }),
});
const json = await res.json();
@@ -85,6 +96,17 @@ export default function RegisterPage() {
+ {inviteToken && (
+
+
+ {t("auth.invitedBanner")}
+
+ )}
+