Merge pull request #663 from MBombeck/release/v1.34.0

v1.34.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-28 23:39:33 +02:00
commit d4223c3a74
443 files changed
+32939 -8757

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ next-env.d.ts
# COPY target always exists.
/assets/geolite2/*.mmdb
# AI session artefacts (kept locally, not committed)
# Local workspace files, never committed
/docs/superpowers/
.claude/
.cursor/
-244
View File
@@ -1,244 +0,0 @@
# AGENTS.md
Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) working on this repository.
## Project
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first PWA design.
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
consolidation, the per-route `/settings/[section]` split, the
status-first admin grid + aggregator endpoint, five new "Test
connection" buttons, AI insights with inline charts via allowlisted
metric tokens, off-host encrypted S3 backup, encryption-key
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
web split, and short-lived 24h access tokens with refresh-token
rotation for native API clients. v1.4.1 follows up with per-section
admin component extraction (the inner monolith into one file per
panel), a Postgres testcontainers integration test suite, and a
Playwright + axe-core E2E foundation. See GitHub Releases +
CHANGELOG.md for the full feature timeline.
## Tech Stack
| Layer | Technology | Version | Notes |
| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- |
| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity |
| ORM | Prisma | 7.8 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` |
| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` |
| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens |
| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) |
| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` |
| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) |
| Testing | Vitest | latest | Config in `vitest.config.mts` |
| Package manager | pnpm | latest | **Not** npm or yarn |
| Node | 20.x | via nvm | |
| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas |
| Auth | SimpleWebAuthn | 13 | Passkeys primary, password fallback |
| i18n | Custom context-based | — | `useTranslations()` hook, `messages/de.json` + `messages/en.json` |
| PDF | jsPDF + autotable | latest | Client-side doctor report generation |
## Commands
```bash
# Development
pnpm dev # Start dev server (http://localhost:3000)
pnpm build # Production build
pnpm lint # ESLint
pnpm format # Prettier format
pnpm format:check # Prettier check
pnpm typecheck # TypeScript strict check
pnpm openapi:generate # Regenerate docs/api/openapi.yaml from Zod schemas
pnpm openapi:check # Verify the spec is in sync (CI gate)
# Git hooks (one-time per clone)
bash scripts/install-hooks.sh # Activates .githooks/, including the OpenAPI pre-commit check
# Tests
pnpm test # Run all tests (vitest)
pnpm test:watch # Watch mode
# Database
pnpm db:generate # Generate Prisma client
pnpm db:migrate # Create & apply migration (dev)
pnpm db:migrate:deploy # Apply migrations (production)
pnpm db:push # Push schema without migration (prototyping)
pnpm db:studio # Prisma Studio GUI
# Docker
docker compose up -d # Start app + postgres
docker compose logs -f app # Tail app logs
# Verification (run before completing tasks)
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
```
## File Layout
```
src/
├── app/ # Pages and API routes (App Router)
│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA)
│ ├── page.tsx # Dashboard (/) with quick entry dropdown
│ ├── globals.css # Dracula theme CSS variables
│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/
│ ├── auth/login/page.tsx # Login
│ ├── auth/register/page.tsx # Registration
│ ├── achievements/page.tsx # Gamification achievements
│ ├── bugreport/page.tsx # Bug report → GitHub issue
│ ├── insights/page.tsx # Health insights + correlations + AI (7 sections with sticky nav)
│ ├── measurements/page.tsx # Measurements CRUD table
│ ├── medications/page.tsx # Medications management
│ ├── notifications/page.tsx # Notification preferences matrix
│ ├── onboarding/page.tsx # 4-step guided onboarding
│ ├── settings/page.tsx # 308-redirects /settings → /settings/account
│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about)
│ ├── mood/page.tsx # Mood tracking
│ ├── targets/page.tsx # Target values dashboard
│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
│ ├── mood-entries/ # Mood CRUD
│ ├── import/ # JSON data import
│ ├── doctor-report/ # Doctor report data aggregation
│ ├── gamification/ # Achievements (persistent)
│ └── ...
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── layout/ # Shell: sidebar-nav, topbar, mobile-nav, bottom-nav
│ ├── medications/ # Medication form, card, timeline
│ ├── measurements/ # Measurement form, list
│ ├── mood/ # Mood form, mood list
│ ├── charts/ # Recharts wrappers, compliance charts
│ ├── insights/ # AI insights cards (status, advisor)
│ ├── gamification/ # Achievement cards, progress
│ ├── settings/ # Per-route Settings section components (one per /settings/[section])
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
│ └── monitoring/ # Umami, GlitchTip bootstrap
├── lib/
│ ├── db.ts # Prisma client singleton
│ ├── crypto.ts # AES-256-GCM encryption
│ ├── gravatar.ts # Gravatar URL from email (SHA-256)
│ ├── api-response.ts # { data, error, meta } envelope helpers
│ ├── rate-limit.ts # In-memory sliding window rate limiter
│ ├── export.ts # CSV/JSON export formatting (incl. mood)
│ ├── doctor-report-pdf.ts # Client-side PDF generation (jsPDF)
│ ├── auth/session.ts # getSession() → { session, user } | null
│ ├── analytics/ # Trend calculations, compliance, correlations
│ ├── i18n/ # I18n context + config
│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push)
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
│ ├── validations/ # Zod schemas (shared between API + client)
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local OpenAI-compat)
│ ├── feedback/ # In-app feedback + optional GitHub escalation
│ ├── gamification/ # Achievement definitions + progress
│ ├── insights/ # Insight pipeline + prompts
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
│ └── withings/ # Withings OAuth client + sync service
├── hooks/
│ └── use-auth.ts # useAuth(), useLogout() hooks
├── generated/prisma/client/ # Generated Prisma client (DO NOT EDIT)
messages/
├── de.json # German translations (primary UI language)
└── en.json # English translations
prisma/
├── schema.prisma # Database schema (26 models)
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
public/
├── sw.js # Service worker (Web Push + offline caching)
├── manifest.json # PWA manifest
└── ... # Static assets
docs.healthlog.dev # External documentation site
```
## Key Conventions
- **Default locale is English**. All code-level strings in English. UI text via i18n (`t()` calls) with German + English translations.
- **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`.
- **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`.
- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Not authenticated", 401);`
- **Timezone**: `Europe/Berlin` for display, UTC in database.
- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`.
- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements.
- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie on all non-public paths. Unauthenticated page requests → redirect `/auth/login`. API routes keep their own `getSession()` auth. Public paths defined in `PUBLIC_PATHS` array.
- **PWA safe area**: Root layout uses `viewportFit: "cover"`. Bottom-nav has `pb-[env(safe-area-inset-bottom)]`. Auth-shell uses `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]`.
- **PDF generation**: Client-side via `src/lib/doctor-report-pdf.ts` using jsPDF. Data fetched from `POST /api/doctor-report`. European medical format with German terminology.
- **Data backup**: pg-boss weekly job (`data-backup` queue, Sundays 03:00), stores compressed JSON in `DataBackup` model.
- **Achievements**: Persistent in `UserAchievement` table. API at `/api/gamification/achievements` computes current state, compares with DB, persists new unlocks. `unlockedAt` timestamp is stable once set.
## Critical Gotchas
These are hard-won lessons. Ignoring them will cause errors:
### Prisma 7
- **No `url` in schema.prisma** — The database URL is configured in `prisma.config.ts`, not in the `datasource` block.
- **Import path**: `import { ... } from "@/generated/prisma/client"` (with `/client` suffix, not `@/generated/prisma`).
- **Adapter required**: PrismaClient needs `PrismaPg` adapter from `@prisma/adapter-pg`. See `src/lib/db.ts`.
- **No `earlyAccess`** in defineConfig — causes TypeScript errors.
### Next.js 16
- **Proxy, not middleware**: Next.js 16 renamed middleware to proxy. The file is `src/proxy.ts` (not `middleware.ts`). Having both causes a build error: "Both middleware file and proxy file detected".
### Libraries
- **SimpleWebAuthn v13**: No `@simplewebauthn/server/script/deps` — define Transport type inline.
- **zxcvbn-typescript**: Default export only. `zxcvbnAsync` and `zxcvbnOptions` do not exist as named exports.
- **pg-boss v12**: `{ PgBoss }` named import (not default), use `localConcurrency` (not `teamSize`), handler receives `Job<T>[]` array.
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
### Settings & Admin (per-route layout, v1.4)
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
### Insights Page
- Large file (~1750 lines), 7 content sections with AI summaries.
- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking.
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `meds`, `bmi`.
- Each section has `scroll-mt-28` for header offset on scroll.
### Sidebar
- Collapsible (icons-only mode, `w-16`). State persisted in localStorage (`healthlog-sidebar-collapsed`).
- User section at bottom: Avatar (Gravatar if email, initials fallback) + username + three-dot MoreVertical menu (right side).
- Three-dot menu: Admin link, Notifications, Theme picker, Logout.
- ESLint enforces `react-hooks/set-state-in-effect` — use lazy `useState(() => ...)` for localStorage reads, NOT `useEffect` + `setState`.
### Gravatar
- `src/lib/gravatar.ts` generates Gravatar URL server-side (SHA-256 hash of email, `?d=404` fallback).
- Returned in `/api/auth/me` response as `gravatarUrl` field. Used by `AvatarImage` component with `AvatarFallback` for initials.
### Notification System
- **Channels**: `telegram`, `ntfy`, `web_push` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`).
- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`.
- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists.
- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type.
- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h.
### Onboarding
- 4-step guided flow: Profile → Medications → Notifications teaser → Target values.
- Optional steps 2-4 have "Skip" buttons.
- Progress indicator bar at top.
- Located at `/onboarding`, linked from proxy as public path.
## Database Models (Prisma)
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
## When Making Changes
1. **Read before modifying** — understand the existing code patterns.
2. **Run verification** after changes: `pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build`
3. **German UI text** — all user-facing strings go through `t("key")` with translations in both `messages/de.json` and `messages/en.json`.
4. **Don't over-engineer** — keep changes focused. No speculative abstractions.
5. **Update `docs/STATUS.md`** when completing tasks.
+155
View File
@@ -2,6 +2,161 @@
## [Unreleased]
## [1.34.0] — 2026-07-28
Two things this release keeps saying to itself. A job, a sync, or a write
that fails can no longer report success by falling off the end of a
function. And a number the app grades you against is either yours or it
says plainly that it is not.
### A failure now looks like a failure
Every background job in this codebase, all 104 of them across five queues,
used to signal success by returning nothing. A handler that caught an
error, logged a warning, and returned was indistinguishable from one that
finished its work, so a queue could fail quietly for as long as it liked.
Every handler now has to construct and return the outcome of what it did,
and the type system enforces it: a handler that falls off the end no
longer compiles. Retry behaviour was reviewed queue by queue rather than
flipped in bulk, because a rethrow changes what runs, not only what shows
up on a screen.
The same fix reached the surfaces you actually watch. Six integration
cards and the lab import dialog used to key their tone off whether the
network call itself succeeded, so a sync that imported nothing because
every reading was refused still rendered a green tick, and a re-scan
where every row turned out to be a duplicate raised a success toast for
zero saved readings. They now render the outcome the server actually
resolved: a tick only when everything landed, a warning when part of it
did not, an error when none of it did.
### Grading you against your own numbers
The Health Score graded your weight against a target nobody set, silently
computed from your height and never shown anywhere. If you had entered
your own weight target on the targets page, the score ignored it. Both
the targets page and the score now read the same target you actually
set, and an account with no target of its own gets a plain trend reading
instead of a number graded against a hidden goal. The score's weight row
now says outright which of the two it used.
The Health Score itself is rebuilt on the same coverage-and-provenance
contract the rest of the app's derived metrics already use. Every pillar
now says what went into it and what did not, weights redistribute openly
when a pillar has nothing to grade instead of quietly changing what the
number means, and the card explains itself the way the other Insights
cards do. Because both of these changes move the same number, they are
explained once: the score carries a version marker, and the first delta
you see after updating is suppressed rather than reported as a real
day-to-day change, with a note on the card saying why.
Steps, active energy, distance, and flights now compare today's running
total against what is typical for you at this same hour, not just
against your latest reading, which never meant anything for a metric
that only makes sense as a full day. It needs at least two weeks of your
own history before it says anything, and until then it says so honestly
instead of guessing from three days of data.
### What was asked for, and built
An account-defined custom metric can now feed the same correlation engine
your built-in metrics already run through, so a value nothing else in the
app tracks can be checked against your own outcomes rather than sitting
in its own silo. Caffeine and nicotine join alcohol as taggable factors in
that same engine. A correlation or crosstab finding can be dismissed once
it has been looked at, and stays dismissed instead of recomputing itself
back into view on every run.
Three facts about you, smoking status, alcohol pattern, and shift or
rotating-schedule work, can now be recorded as dated, correctable entries
rather than buried in free text, with their own switch for whether they
reach the Coach's prompt at all.
The "worth a look" rail on the dashboard can now be trimmed to the kinds
of item you actually want to see there. Turning a tile off there only
hides it; it does not touch the medication reminder itself, which keeps
firing on its own channel regardless of what the hero card shows.
### Two defects, found by people who read the code and the numbers
A workout synced from Google Health with a sport type the app's mapping
table did not recognise lost that type outright and rendered as
"Other," identical to a workout that genuinely had no category. The
original label is now kept on the row, the same way three other
providers already keep theirs; a full re-sync repairs workouts already
written under the old behaviour.
A same-time baseline band for a metric that cannot go negative, such as
step count, could report a lower edge below zero. One call site is now
required to name which metric it is drawing a band for, so the floor
comes from that metric's own declared range rather than being patched in
by hand at each caller.
**Reminders for a multi-dose schedule stopped matching the dose they were
meant for.** The reminder tick computed one window length for a whole
schedule and applied it to every time slot in it, so a twice-daily
schedule gave its first dose a ten-hour grace period nobody configured
while a single-dose schedule with a narrow window escalated correctly but
then had almost no room to register a dose logged late. Each slot now
resolves its own window: an explicit per-dose override first, then the
schedule's own grace setting, then the same default every other
unconfigured dose gets, bounded so two doses in one day can never share a
window.
A Fitbit workout kept only its canonical category and dropped the raw
activity name and type id it arrived with, unlike every other provider
this app syncs from. An unrecognised Fitbit activity had nothing left to
diagnose it by. Both are now kept on the row, and carried through backup
and restore the same way.
### The backup
Your custom metrics and their readings, and the encrypted health-profile
facts above, are now carried by export and restore. Neither was before;
a restore silently rebuilt an account without either and reported
success. The hourly shape behind the same-time baseline is also carried,
because past a short grace window it is the only copy of that curve
anywhere. And the summary shown after uploading a backup now counts every
record class it restores, including the nutrient day totals it was
quietly leaving out of its own total.
### For operators
Four migrations. `0283` adds the intraday cumulative profile table,
`0284` adds the effective-dated health-profile fact table and its
inclusion enum, `0285` adds correlation-pattern identity, `0286` seeds
the caffeine and nicotine mood tags. None deletes or rewrites a
measurement.
### Credit
Reported by @lutzkind (#613, #614, #616, #617, #649, and a long run of
earlier reports this release also carries forward). Reported by
@tarantila, directly rather than on the tracker: the Google workout-type
loss, and a below-zero baseline reading that turned out to affect
other metrics once someone went looking. Built by @mathewcsims
(Refs #662): the per-slot reminder window fix.
### Thank you
Eighteen people have opened a report or a request on this tracker so
far, most of them owing this project nothing. @lutzkind alone has filed
twenty-four, several arriving with the broken code path already traced.
@tarantila reports straight to the maintainer and has twice turned one
observation into a defect nobody else had found. This release carries
what a good number of those reports and requests turned into. It is not
all of them, and the ones still open are still open because they are
waiting their turn, not because they were set aside.
@StefanIndustries sent this project its first money, ten euros, toward
the yearly cost of keeping the iOS client on TestFlight without asking
anyone to pay for it. The amount was never the point. Someone deciding
this was worth something, and saying so, was not expected here.
More is welcome: an idea, a rough thought about how something ought to
work, a screenshot of a number that looks wrong. Especially that last
one.
## [1.33.2] — 2026-07-28
### The cycle-symptom fix in v1.33.1 did not work
-206
View File
@@ -1,206 +0,0 @@
# Contributor notes
Working notes for anyone (human or otherwise) editing this repository. Skim before the first PR; refer back when a convention question comes up. Pair with `README.md` (the public face) and `CHANGELOG.md` (the release history); this file is the maintainer's operating manual.
## What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.20.x.
## Voice and privacy
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
- **Voice.** English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub — including commit messages and trailers. Commits carry no `Co-Authored-By` tooling trailer; they are authored by the maintainer's git identity alone.
- **No personal data.** No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal `.planning/*` files are gitignored and exempt; everywhere else in the repo, no.
If a draft commit message or release note would violate one of the three, rewrite it before committing. The `git log` is more durable than today's task.
## Branch and release model
`main` is the single trunk and is always releasable. There is no long-lived `develop` branch — the former one was retired and archived as the tag `archive/develop-pre-v1.5` (it carried no work absent from `main`; a long-lived integration branch has no role in a single-maintainer shop and only accrued back-merge debt). This is trunk-based development with release tags: short-lived branches off `main`, work always converges back onto `main`, and the immutable `vX.Y.Z` image built from a tag on `main` is the one source of truth — including for the OpenAPI contract the iOS client consumes.
- **Trunk.** `main` always builds, passes CI, and is deployable. Every commit on it is a release commit (`chore(release): vX.Y.Z`), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly.
- **A release** is built on a short-lived `release/vX.Y.Z` branch cut off `main` — built wave-by-wave, then opened as a PR `release/vX.Y.Z` → `main` with green CI, merged (merge-commit), and tagged `vX.Y.Z`. The tag push triggers `docker-publish.yml` (GHCR multi-arch + `:latest`). Deploy is manual (see `docs/ops/deploy.md`); after a deploy, verify `/api/version` (it returns `version` + `buildSha` + `builtAt`) on every target, since the `:latest` pull is the source of truth, not the queued deploy status. Delete the release branch after merge.
- **Small standalone changes** (a single fix, a docs touch) can go through a short-lived `fix/X` / `feat/X` branch + PR, or directly onto `main` when the maintainer chooses — `main` is the target either way.
- **Hotfixes** branch from `main` and merge straight back to `main`. There is no second long-lived line to forward-port to, so the old back-merge debt cannot recur by construction.
- Never force-push `main` or any release branch. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
## Semver
Prefer patch bumps. A release dominated by bug fixes plus additive features without breaking changes ships as `vX.Y.Z+1`, not `vX.Y+1.0`. Bumping minor is reserved for breaking changes, removed flags, deprecated routes, or multi-feature milestones framed as such (e.g. v1.5 = native iOS client + Apple Health integration). Pre-existing example: a `v1.5.0` cut was rerouted to `v1.4.14` mid-flight when the diff turned out to be bugfix-dominated.
## Tech stack at a glance
| Layer | Tech | Notes |
| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 115 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
## How a request flows
```
client
│ HTTP
▼
src/proxy.ts renamed from middleware.ts. Public-path allowlist,
│ demo-mode mutation block, server-side onboarding
│ redirect, worker-only refusal, request-id + nonce
│ generation, every security header.
▼
src/app/<route>/route.ts every API route wraps in `apiHandler(...)`
│
▼
src/lib/api-handler.ts WideEventBuilder + AsyncLocalStorage, auth resolver
│ (cookie OR Bearer), idempotency, error envelope,
│ GlitchTip forwarder. Cookie path can elevate to
│ admin via requireAdmin(); Bearer never can.
▼
handler body Zod parse (`safeParse` + `returnAllZodIssues`),
│ business logic, `annotate({ action, meta })`,
│ `auditLog()` for sensitive ops.
▼
src/lib/api-response.ts `apiSuccess(data, status?)` → `{ data, error: null }`;
│ `apiError(msg, status, meta?)` threads `meta` on the
│ error path only — `{ data: null, error, meta? }`.
▼
src/proxy.ts security headers attach, request-id echoes.
▼
client
```
The architecture map in `.planning/codebase/arch.md` walks each layer with file:line citations.
## Code conventions
- **UI work follows the design standards.** `.planning/audits/2026-07-05-fable/UI-STANDARDS.md` is the binding design rulebook (card anatomy via TileHeader, spacing scale, text-colour rules — foreground for content, muted for meta only —, token discipline, primitive mapping). Read it before touching any surface; deviations need a written reason in the PR. Where the standards conflict with older in-file comments, the standards win.
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **A two-ended change carries both ends.** Adding a column, a payload field, or a user preference without its consumer is the failure mode this repo keeps rediscovering: the column ships with the schema change, the reader is "the follow-up", and nothing in the gate notices because every other check proves one end. So the consumer lands in the same release branch, or the schema comment says why it cannot: `/// @internal: <reason>` for something deliberately never surfaced, `/// @pending-consumer(#issue)` for a consumer that is genuinely still coming. `pnpm dlx tsx scripts/audit-column-readers.ts` sweeps for unannotated write-only columns and re-lists every `@pending-consumer` marker so a follow-up that never happened surfaces at the next release audit. Run it in the deep-audit step; it is a review tool with a grep-shaped matcher, deliberately not a gate. The two questions that can be answered exactly are gates instead — `account-payload-consumer-guard.test.ts` (every `/api/auth/me` field has a client reader) and `measurement-backup-completeness.test.ts` (every `Measurement` column reaches the backup).
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename.
## Tests and commands
- `pnpm typecheck` — `tsc --noEmit`. Required before every commit.
- `pnpm lint` — ESLint flat config with `eslint-config-next` + the project's `healthlog/queryKey-factory` rule. One harmless warning in `withings/resume/__tests__/route.test.ts` is allowed; everything else should be clean.
- `pnpm test` — Vitest 4 unit + component suite. Required before pushing a fix; full suite is fast (~30 s).
- `pnpm test:integration` — Vitest against a `testcontainers/postgresql` Postgres 16. Needs Docker / OrbStack running. Slower but pins the contracts the unit-mocks can't.
- `pnpm e2e` — Playwright against `pnpm exec next build` + Postgres. Heavy; CI runs it, locally only when touching UI flows.
- `pnpm openapi:generate` — regenerates `docs/api/openapi.yaml` from the Zod registry. Run after any request / response schema change. CI's `openapi:check` fails the build on drift.
- `pnpm check-env` — validates that `.env.production.example` covers every required variable. Wired into CI as `env-check.yml`.
- `pnpm bundle-report` / `ANALYZE=1 pnpm build` — bundle inspection. Use when a feature adds material weight.
- One-shot scripts live in `scripts/`. Run them via `pnpm dlx tsx scripts/<file>.ts` — the production standalone image strips `tsx` and the bare `pnpm tsx ...` invocation fails inside the container. Recurring tasks belong on pg-boss, not on a CLI script.
## Critical-files map
| Concern | File | What it owns |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
| Schema | `prisma/schema.prisma` (115 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
## Security-relevant patterns
The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of per-domain findings under `.planning/security-audit-findings/`. The patterns worth preserving on future churn:
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
- **CSP: strict, per-request nonce, surgical `connect-src`.** Production CSP gates third-party hosts to the routes that need them — AI hosts only on `/settings/ai/*`, `wbsapi.withings.net` only on `/settings/integrations/withings/*` + `/api/withings/*`. Exactly one `dangerouslySetInnerHTML` in the tree (the theme bootstrap in `src/app/layout.tsx`, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults.
- **No CORS headers anywhere.** Same-origin-only by construction. Adding `Access-Control-Allow-Origin: *` anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie.
- **Redaction at the egress boundary, not at the call site.** `redactSecrets()` + `redactSensitiveFields()` + `redactOptional()` are applied centrally in `WideEventBuilder` and `reportToGlitchtip`. Don't add new redactor calls in handler bodies — extend the central denylists in `src/lib/observability/redact-payload.ts` and `src/lib/logging/redact.ts`.
## Self-hosting gotchas
These keep tripping operators; they're worth knowing before you push something that breaks them.
- **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression.
- **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`.
- **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"<hex>","v2":"<hex>"}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key.
- **GeoLite2 databases.** Two ways in. Building your own image: run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build` and the MMDBs bake into `/opt/geolite2/`. Running the published image: mount a directory of `.mmdb` files and point `GEOLITE2_DIR` at it — it is on the compose `environment:` whitelist and documented in `docs/self-hosting/geolite2.md`. Without either, the resolver falls back to the online provider and every login IP leaves the host.
- **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds.
## iOS handoff
The native SwiftUI client lives in a separate repository and rides public beta via TestFlight. Backend contract the iOS app speaks against is locked in `docs/api/openapi.yaml` and has been continuously validated since v1.4.23. Coordination notes worth knowing on the backend side:
- `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (e.g. `stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD`) as **overwrites** — a re-post replaces the row's value / unit / measuredAt / externalSourceVersion / deviceType / sleepStage. Every other externalId prefix (`uuid-*`, opaque HK identifiers) keeps the first-write-wins immutable contract because each sample is a canonical reading. Per-entry status field carries `"updated"` for an overwrite vs `"inserted"` for a fresh row.
- Refresh-token rotation is per-device; reuse-detection revokes the family scoped to that device.
- `requireAdmin()` is cookie-only by construction — a Bearer token, even with `["*"]` scope, cannot reach `/api/admin/*`. The iOS app is not an admin client.
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
## DO-NOTs
- **No `--no-verify` on commits.** If a pre-commit hook fails, fix the cause; never bypass.
- **No `--no-gpg-sign` unless explicitly asked.**
- **No force-push to `main`** (or to release branches, by extension).
- **No destructive git operations as shortcuts.** `git reset --hard`, `git clean -fd`, `git checkout .` etc. only when the maintainer asked or the state is unambiguously local-only.
- **No assistant / planning vocabulary in any committed artefact** — see Voice and privacy above.
- **No personal names** — the maintainer's, a reporter's, or anyone else's — in any committed artefact, including code comments referring to "the user who reported issue #X".
- **No secret-shaped strings in `.env.example` or git history.** Pre-existing scan shows clean.
- **No markdown library in dependencies.** Coach + briefing + insights render as React text children for XSS reasons; adding one is a security decision, not a UX one.
- **No `Access-Control-Allow-Origin` headers** anywhere except the documented webhook surfaces — and those authenticate via header secret, not cookie.
- **No `pnpm tsx scripts/foo.ts` in production.** The standalone image strips `tsx`. One-shot scripts run via `pnpm dlx tsx`; recurring tasks belong on pg-boss.
- **No backwards-compatibility shims for hypothetical callers.** When a change is clean, make it cleanly; do not leave dead branches "in case" something we don't ship needs them.
## Pointers
| What | Where |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public face | `README.md` |
| Release history | `CHANGELOG.md` |
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
| Self-hosting guide | `docs/self-hosting/` |
| Public API contract | `docs/api/openapi.yaml` |
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
| iOS coordination notes | `.planning/ios-coord/` |
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
## Agent context hygiene (ECONNRESET / oversized-request prevention)
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.)
- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads.
- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions.
+2 -2
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Re-measured at v1.33.0: / 429, /insights 414, /measurements 416, /insights/mood 432, total 3060, baseline 130. The aggregate rose 78 KB from v1.32.0's 2982 because that release adds surfaces rather than libraries: a dose-history import card, inventory date editing, fourteen confirmation dialogs, and a job-failure panel. Every per-route cap still passes with headroom, and the thing this aggregate exists to catch (a newly duplicated ~90 KB library) did not happen: the Recharts chunk count is still 1 and structurally guarded. Raised 3050 to 3120 for that reason, not to make a red check green. Route caps retain focused headroom. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Re-measured at v1.34.0: / 430, /insights 417, /measurements 417, /insights/mood 435, total 3130, baseline 130. The aggregate rose 70 KB from v1.33.2's 3060 for the same reason the v1.33.0 raise did: new surfaces, not a duplicated library. This release adds the reference-composite health-score card (per-pillar detail cards, method notes), the structured anamnesis facts editor, custom-metric correlation opt-in, and pattern-dismissal controls on two card types. Every per-route cap still passes with 23-30 KB headroom, and the Recharts chunk count is still 1 and structurally guarded. Raised 3120 to 3140 for that reason, not to make a red gate pass — the per-route budgets are the real ceiling on any single page; the aggregate exists to catch exactly the duplicated-library case that did not happen here.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
"totalClientKbGz": 3120,
"totalClientKbGz": 3140,
"maxRechartsChunks": 1
}
+1010 -76
View File
File diff suppressed because it is too large. Load diff
+97 -10
View File
@@ -1,7 +1,10 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.19 A6 — Settings consistency snapshot at Pixel-5 (393 CSS px).
* v1.4.27 — input height floor lifted from 36 px (`h-9`) to 40 px
@@ -180,16 +183,100 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
);
});
test("/settings/dashboard: Compare-to trigger renders at 44 px", async ({
page,
}) => {
await page.goto("/settings/dashboard", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
test("/: Compare-to trigger renders at 44 px", async ({ page }) => {
// v1.34.0 — the dashboard-settings comparison-baseline control was
// removed; the same preference now lives behind the per-chart overlay
// popover (see chart-overlay-controls.spec.ts for the full open/toggle
// flow). That popover only paints once a chart with a real chartKey
// renders, so this test needs the same seeded-chart mocks that spec
// uses rather than the real (likely-empty) e2e database.
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78.5,
avg7: 78.2,
avg30: 77.9,
slope30: { slope: -0.05, direction: "down" },
count: 30,
},
},
bpInTargetPct: 0,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.route("**/api/measurements*", (route) => {
const measurements = Array.from({ length: 10 }, (_, i) => ({
id: `m_${i}`,
type: "WEIGHT",
value: 78 + (i % 3) - 1,
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
notes: null,
}));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
const trigger = page.locator("#comparison-baseline");
await expect(trigger).toBeVisible();
const h = await trigger.evaluate((el) => el.getBoundingClientRect().height);
expect(Math.round(h)).toBe(44);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="dashboard-tile-strip"]'),
).toBeVisible({ timeout: 10_000 });
const trigger = page
.locator('[data-slot="chart-overlay-controls-trigger"]')
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.scrollIntoViewIfNeeded();
await trigger.click();
// `data-slot="chart-overlay-comparison-baseline"` is the grid WRAPPER
// (line 235 in chart-overlay-controls.tsx); the buttons themselves are
// `data-slot="chart-overlay-comparison-{none|lastMonth|lastYear}"`.
// Match only the wrapper's children so the wrapper's own (differently
// sized) box never gets measured as if it were a tap target.
const baselineButton = page
.locator(
'[data-slot="chart-overlay-comparison-baseline"] [data-slot^="chart-overlay-comparison-"]',
)
.first();
await expect(baselineButton).toBeVisible({ timeout: 5_000 });
// `min-h-11` sets the CSS contract (`min-height: 44px`), the same
// guarantee `h-11` gives the other 44px checks in this spec. This
// element additionally carries an explicit `h-8` from the Button "sm"
// size variant that `min-height` overrides; on Playwright's Pixel 5
// profile (fractional deviceScaleFactor) that combination measures a
// `getBoundingClientRect().height` a device pixel or two under 44 even
// though the computed CSS is exactly 44px — a viewport-emulation
// rounding artifact, not a real layout regression. Assert the
// authoritative computed style instead of the physically-snapped rect.
const minHeight = await baselineButton.evaluate(
(el) => getComputedStyle(el).minHeight,
);
expect(minHeight).toBe("44px");
});
test("/settings/ai: every native select renders at 44 px", async ({
@@ -0,0 +1,67 @@
/**
* Unit tests for the `healthlog/job-handler-outcome` rule — the bare
* `boss.work(` ban, the one exempt module that owns `createAndWork`, the
* test-file exemption, and the scope limit to `src/lib/jobs/`.
*
* The `invalid` cases below also stand as the rule's own documented blind
* spots: an aliased or renamed receiver is not detected, and neither is a
* handler that returns `{ ok: true }` after swallowing. Those sit in `valid`
* on purpose, so a future reader can see what this rule does not claim.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../job-handler-outcome.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: 2023, sourceType: "module" },
});
ruleTester.run("job-handler-outcome", rule, {
valid: [
{
code: "await createAndWork(boss, QUEUE, { localConcurrency: 1 }, handleX);",
filename: "/repo/src/lib/jobs/reminder/register-status.ts",
},
{
// The wrapper's own call — this module is the one legal caller.
code: "await boss.work(queue, options, runJob(queue, handler));",
filename: "/repo/src/lib/jobs/reminder/registrar-shared.ts",
},
{
// Tests mock `boss.work` and assert against it directly.
code: 'boss.work("q", {}, async () => {});',
filename: "/repo/src/lib/jobs/__tests__/whoop-queues.test.ts",
},
{
// Out of scope: not the worker tree.
code: 'boss.work("q", {}, async () => {});',
filename: "/repo/src/lib/integrations/status.ts",
},
{
// Documented blind spot: an aliased receiver is not detected.
code: 'const b = boss; b.work("q", {}, async () => {});',
filename: "/repo/src/lib/jobs/reminder/register-rollup.ts",
},
{
// Documented blind spot: this rule cannot see a swallowed failure.
code: "await createAndWork(boss, QUEUE, {}, async () => ({ ok: true, did: {} }));",
filename: "/repo/src/lib/jobs/reminder/register-rollup.ts",
},
],
invalid: [
{
code: 'await boss.work("scratch", async () => {});',
filename: "/repo/src/lib/jobs/reminder/register-maintenance.ts",
errors: [{ messageId: "bareBossWork" }],
},
{
code: "await boss.work(QUEUE, { localConcurrency: 1 }, handleX);",
filename: "/repo/src/lib/jobs/worker-status.ts",
errors: [{ messageId: "bareBossWork" }],
},
],
});
+4
View File
@@ -8,6 +8,8 @@
* - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots,
* and no off-scale `5` step on a bg-card shell.
* - `job-handler-outcome` — pg-boss bindings route through createAndWork,
* whose handler returns a JobOutcome.
*/
"use strict";
@@ -17,6 +19,7 @@ const safeFetchRequired = require("./safe-fetch-required.js");
const apiFetchRequired = require("./api-fetch-required.js");
const noRawPaletteColor = require("./no-raw-palette-color.js");
const spacingScale = require("./spacing-scale.js");
const jobHandlerOutcome = require("./job-handler-outcome.js");
module.exports = {
rules: {
@@ -30,5 +33,6 @@ module.exports = {
// named rule (now error-level too; the staged warn phase ended with
// the semantic sweep). See the rule header.
"no-dracula-utility": noRawPaletteColor,
"job-handler-outcome": jobHandlerOutcome,
},
};
@@ -0,0 +1,107 @@
/**
* @fileoverview ESLint rule — a pg-boss queue binding must go through
* `createAndWork`, never a bare `boss.work(`.
*
* `boss.work(queue, options, handler)` completes a job whenever the handler's
* promise resolves, whatever it resolves to. Every binding in this tree used
* to be written that way, so a handler that caught an error, logged a warning
* and returned looked exactly like one that did its work. `createAndWork`
* (`src/lib/jobs/reminder/registrar-shared.ts`) types its handler as
* `(jobs) => Promise<JobOutcome>` and wraps it in `runJob`, which fails the
* pg-boss job on `ok: false`. Routing every binding through it is what makes
* "the handler returned nothing" a compile error rather than a silent pass.
*
* WHAT THIS RULE PROVES, AND WHAT IT DOES NOT
*
* It proves that no queue in this tree is bound by a path that accepts a
* void handler. That is all it proves. It does NOT detect a handler that
* returns `{ ok: true }` after swallowing an exception — that compiles, it
* passes this rule, and it is exactly as invisible as the defect the outcome
* type was written for. The rule converts an omission (forgetting to signal
* failure) into a commission (claiming a success that did not happen). The
* second is a smaller class and a reviewable one; it is not zero.
*
* Two further evasions are known and unhandled: aliasing the method
* (`const w = boss.work.bind(boss)`, `const { work } = boss`) and calling
* `work` on an object not named `boss`. Both are syntactic dodges a reviewer
* would see in the diff; neither is defended against here.
*
* Scope: `src/lib/jobs/`, excluding the registrar module that owns the one
* legal `boss.work` call and excluding test files, which mock `boss.work`
* and assert against it directly.
*
* @see src/lib/jobs/run-job.ts
* @see src/lib/jobs/job-outcome.ts
* @see src/lib/jobs/reminder/registrar-shared.ts
*/
"use strict";
// The one module allowed to call `boss.work` — `createAndWork` lives here
// and is the wrapper every other binding has to use.
const EXEMPT_FILES = ["src/lib/jobs/reminder/registrar-shared.ts"];
// The worker tree. A `boss.work` outside it would not be a queue binding.
const ENFORCED_ROOTS = ["src/lib/jobs/"];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** @type {import("eslint").Rule.RuleModule} */
const jobHandlerOutcomeRule = {
meta: {
type: "problem",
docs: {
description:
"pg-boss queue bindings must go through createAndWork so the handler returns a JobOutcome.",
},
schema: [],
messages: {
bareBossWork:
'boss.work() completes a job whenever the handler resolves, so a swallowed failure reads as success. Bind the queue with createAndWork(boss, QUEUE, options, handler) from "./registrar-shared" — its handler type is (jobs) => Promise<JobOutcome> and runJob fails the job on ok:false.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
return {
CallExpression(node) {
const callee = node.callee;
if (
callee.type !== "MemberExpression" ||
callee.computed ||
callee.property.type !== "Identifier" ||
callee.property.name !== "work" ||
callee.object.type !== "Identifier" ||
callee.object.name !== "boss"
) {
return;
}
context.report({ node, messageId: "bareBossWork" });
},
};
},
};
module.exports = jobHandlerOutcomeRule;
+7
View File
@@ -75,6 +75,13 @@ const eslintConfig = defineConfig([
// trip it. Density lives on the Card itself (`gap-2 py-3 md:py-4`) or on
// a swept `p-4 md:p-6` shell. See the rule header.
"healthlog/spacing-scale": "error",
// Every pg-boss queue binding under src/lib/jobs goes through
// `createAndWork`, whose handler type is `(jobs) => Promise<JobOutcome>`.
// A bare `boss.work(` accepts a handler that resolves to nothing, which
// is how a swallowed failure came to read as a completed job. The rule
// catches the omission only — a handler that returns `{ ok: true }`
// after swallowing still passes, and its header says so.
"healthlog/job-handler-outcome": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
+212 -48
View File
@@ -124,6 +124,15 @@
},
"doctorReport": {
"title": "Gesundheitsbericht",
"anamnesisTitle": "Gesundheitsprofil",
"anamnesisNotRecorded": "Nicht erfasst",
"anamnesisUnreadable": "Gespeicherter Wert konnte nicht gelesen werden",
"anamnesisConditions": "Chronische Erkrankungen",
"anamnesisSmoking": "Rauchstatus",
"anamnesisAlcohol": "Alkoholkonsummuster",
"anamnesisShiftSchedule": "Arbeitszeitmodell",
"anamnesisColFact": "Profilangabe",
"anamnesisColValue": "Erfasster Wert",
"subtitle": "HealthLog — Persönlicher Gesundheitsbericht",
"patient": "Patient",
"dateOfBirth": "Geburtsdatum",
@@ -396,10 +405,6 @@
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dosen heute"
},
"greetingSalutation": "Hallo, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
@@ -452,11 +457,22 @@
"compliance7d": "Therapietreue (7 Tage)",
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"scoreRingsTitle": "Score-Ringe",
"scoreRingsDescription": "Bis zu drei zusätzliche Ringe neben dem Health Score – zum Beispiel Bereitschaft, Erholung, Schlaf oder Therapietreue. Zum Umsortieren ziehen; der Health Score steht standardmäßig vorne.",
"heroItemsTitle": "Highlights für heute",
"heroItemsDescription": "Wähle aus, welche Eintragstypen in den Highlights für heute erscheinen.",
"heroItemsNotificationNote": "Benachrichtigungseinstellungen werden separat verwaltet.",
"heroItems": {
"coach_checkin": "Coach-Check-ins",
"dose_window": "Medikamentendosen",
"preventive_care": "Vorsorge",
"sync_issue": "Synchronisierungsprobleme",
"milestone": "Meilensteine",
"ecg_new_recording": "Neue EKG-Aufzeichnungen",
"tension_window": "Anspannungsphasen",
"same_time_baseline": "Aktivität zur gleichen Tageszeit"
},
"layoutReset": "Auf Defaults zurücksetzen",
"layoutResetTitle": "Dashboard-Layout zurücksetzen?",
"layoutResetBody": "Deine Kachel-Reihenfolge, die ausgeblendeten Kacheln und die gewählten Ringe kehren zu den Standardwerten zurück. Die Anordnung wird nirgends sonst gespeichert.",
"layoutResetBody": "Dein Kachel-Layout und deine Auswahl für die Highlights für heute werden auf die Standardwerte zurückgesetzt.",
"layoutResetConfirm": "Layout zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
@@ -773,6 +789,8 @@
"hydrated": "Wasser getrunken",
"walked": "spazieren",
"alcohol": "Alkohol",
"caffeine": "Koffein",
"nicotine": "Nikotin",
"family": "Familie",
"friends": "Freunde",
"party": "Party",
@@ -2081,7 +2099,6 @@
},
"comparison": {
"toggleLabel": "Vergleichen mit",
"toggleHint": "Vergleicht den aktuellen Zeitraum mit dem Vorjahreszeitraum.",
"baseline": {
"none": "Aus",
"lastMonth": "Vormonat",
@@ -2101,6 +2118,15 @@
"notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
},
"insights": {
"pattern": {
"dismiss": "Ausblenden",
"dismissed": "Dieses Muster bleibt ausgeblendet, bis sich die Evidenz ändert.",
"restore": "Wieder anzeigen",
"dismissError": "Dieses Muster konnte nicht ausgeblendet werden.",
"restoreError": "Dieses Muster konnte nicht wiederhergestellt werden.",
"loadError": "Muster konnten nicht geladen werden.",
"empty": "Kein aktuelles Muster hat die Evidenzschwelle erreicht."
},
"correlation": {
"direction": {
"higher": "höher",
@@ -2226,34 +2252,86 @@
"heroActionAskCoach": "Coach fragen",
"healthScore": {
"label": "Gesundheitsscore",
"progressAria": "Gesundheitsscore-Fortschritt",
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
"restModePaused": "Während einer Krankheitsphase pausiert — dein Score wird heute nicht bewertet.",
"componentBp": "Blutdruck",
"componentWeight": "Gewicht",
"componentMood": "Stimmung",
"componentCompliance": "Therapietreue",
"deltaExplainer": {
"title": "So wird die Veränderung berechnet",
"description": "Veränderung gegenüber letzter Woche: {delta}",
"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."
"algorithmChanged": "Der Score nutzt jetzt veröffentlichte kardiometabolische Referenzbereiche; Gewicht ist nicht mehr enthalten, weil das frühere Ziel aus der Körpergröße statt aus einem selbst gesetzten Ziel abgeleitet wurde, tägliche Stimmungsschwankungen und Medikamententreue sind Handlungen statt Gesundheitszustände, und für Ruhepuls und HRV gibt es keinen allgemeinen Zielwert. Der Gewichtsfortschritt bleibt unten als persönlicher Kontext sichtbar, die anderen Signale bleiben auf ihren eigenen Karten, und eine neue Zahl bedeutet keine Veränderung deiner Gesundheit.",
"dismissAlgorithmChanged": "Hinweis zur Score-Methode schließen",
"band": {
"green": "Im Referenzbereich",
"yellow": "Einige Bereiche brauchen Aufmerksamkeit",
"red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs"
},
"provenance": {
"toggle": "Zusammensetzung",
"mixedBanner": "Einige Komponenten kombinieren mehrere Quellen. Die Aufschlüsselung zeigt den aktuellsten Wert je Komponente.",
"footnote": "Anteile sind nach verfügbaren Daten neu skaliert.",
"asOfLabel": "Stand {date}",
"provisional": "Vorläufig — nur {count} von {total} Datenpunkten verfügbar",
"provisionalBadge": "Vorläufig",
"sourceAria": "Quelle: {source}",
"sources": {
"manual": "Manuell",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Gemischt",
"none": "Keine Daten"
}
"method": "Gleich gewichteter Mittelwert der geeigneten Bereiche. Der niedrigste Bereich bestimmt die Gesamtstufe.",
"insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.",
"composition": "Einbezogen: {pillars}",
"bandSetter": "Gesamtstufe bestimmt durch {pillar}",
"versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.",
"methodVersion": "Methodenversion {version}.",
"none": "keiner",
"delta": "{delta} gegenüber dem vorherigen vergleichbaren Zeitraum",
"deltaReason": {
"algorithm_changed": "Kein Vergleich, weil sich die Score-Methode geändert hat.",
"composition_changed": "Kein Vergleich, weil sich die einbezogenen Bereiche geändert haben.",
"first_eligibility_window": "Kein Vergleich, weil dies der erste geeignete Zeitraum ist.",
"below_noise_floor": "Die Veränderung liegt innerhalb der gemeinsamen Messschwankung.",
"no_previous_window": "Es ist kein früherer vergleichbarer Zeitraum verfügbar.",
"no_current_score": "Kein Vergleich, weil der aktuelle Zeitraum nicht geeignet ist."
},
"restMode": "Ruhemodus aktiv seit {since}. Der Score dient nur als Kontext und wird nicht verschlechtert.",
"pillar": {
"bloodPressure": "Blutdruck",
"glycaemia": "Blutzucker",
"activity": "Aktivität",
"sleep": "Schlaf",
"adiposity": "Körperfettverteilung",
"wellbeing": "Wohlbefinden",
"fitness": "Gemessene Fitness",
"lipids": "Blutfette"
},
"observed": "Beobachtet: {value}",
"reference": "Referenz: {value}",
"fastingObserved": "Nüchternglukose: {value} {unit}",
"fastingReference": "{low} bis {high} {unit} nüchtern",
"personalReference": "Dein Ziel: {value}",
"source": "Quelle: {source}, Stand {date}",
"observedActivity": "{value} Schritte pro Tag",
"observedSleep": "{value} Minuten pro Nacht",
"observedAdiposity": "Taille-zu-Körpergröße-Verhältnis {value}",
"observedFitness": "{value} {unit}, gemessen",
"referenceActivity": "Der Nutzen flacht bei {high} Schritten pro Tag ab",
"referenceSleep": "Mindestens {low} Minuten pro Nacht; längerer Schlaf wird nicht abgewertet",
"referenceAdiposity": "Taille-zu-Körpergröße-Verhältnis unter {high}",
"referenceFitness": "{low} bis {high} {unit}, Referenzbereich nach Alter und Geschlecht",
"referenceHba1c": "HbA1c {low} bis {high} %",
"referenceDetail": "{label} · {source} · {kind} · {low} bis {high}",
"openBound": "offen",
"laboratoryReferenceSource": "Referenzbereiche des auswertenden Labors",
"personalTargetSource": "Persönliches Ziel",
"referenceKind": {
"clinical": "Klinischer Schwellenwert",
"population": "Bevölkerungsperzentil",
"guideline": "Leitlinienbereich"
},
"reason": {
"readFailed": "Daten konnten nicht geladen werden",
"notTracked": "Nicht erfasst",
"missingProfile": "Alter oder Referenzprofil fehlt",
"missingHeight": "Körpergröße fehlt",
"incompletePanel": "Ein aktuelles vollständiges Panel mit Referenzbereichen ist erforderlich",
"crisis": "Anstelle eines Scores werden Sicherheitshinweise angezeigt",
"insufficientHistory": "Nicht genügend aktuelle geeignete Daten",
"unavailable": "Dieser Bereich kann derzeit nicht bewertet werden"
},
"pillarInsufficient": "Kein Bereichsscore: {reason}",
"tension": "Die Bereitschaftssignale unterscheiden sich. Unterstützend: {positive}. Belastet: {negative}.",
"returnToBand": "{metric} liegt seit {days} Tagen wieder im üblichen Bereich.",
"weightGoal": {
"title": "Persönliches Gewichtsziel",
"value": "Aktuell {current} {unit}. Ziel {min} bis {max} {unit}. Abstand zum Ziel: {distance} {unit}.",
"delta": "Der Abstand zum Ziel hat sich seit der vorherigen Messung um {delta} {unit} verändert.",
"noDelta": "Für einen Vergleich ist keine frühere Gewichtsmessung verfügbar.",
"no_personal_goal": "Lege ein persönliches Gewichtsziel fest, um hier den Fortschritt zu sehen.",
"weight_not_tracked": "Erfasse dein Gewicht, um den Fortschritt zum Ziel zu sehen.",
"read_failed": "Gewichtsdaten konnten nicht geladen werden. Versuche es erneut.",
"notScored": "Der Fortschritt zum Gewichtsziel ist persönlicher Kontext und beeinflusst den Gesundheitsscore nicht."
}
},
"suggestedPrompts": {
@@ -2856,6 +2934,14 @@
"scoresBack": "Zurück zu Insights",
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts. HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
},
"units": {
"steps": "Schritte",
"flights": "Etagen",
"falls": "Stürze",
"years": "Jahre",
"breathingEvents": "Ereignisse",
"respiratoryRate": "Atemzüge/min"
},
"sleep": {
"title": "Schlaf",
"description": "Phasen-Aufschlüsselung und nächtliche Dauer – gezogen aus Apple Health.",
@@ -3640,6 +3726,10 @@
"TRAJECTORY": {
"method": "Eine Ausgleichsgerade (kleinste Quadrate), an deine jüngsten Tageswerte angepasst und über einen kurzen Zeitraum fortgeschrieben — mit dem klassischen Vorhersageintervall, das umso breiter wird, je weiter es reicht. Wird nur berechnet, wenn der jüngste Trend deutlich genug und deine Datenreihe lang und aktuell genug ist; sonst wird keine Linie gezeichnet.",
"caveat": "Eine Fortschreibung deines eigenen jüngsten Trends — keine Vorhersage, kein Ziel und keine klinische Prognose. Sie gilt nur, solange das jüngste Muster anhält; das schattierte Band zeigt, wie schnell diese Sicherheit nachlässt."
},
"SAME_TIME_BASELINE": {
"method": "Dein heutiger laufender Gesamtwert im Vergleich zum Median derselben Ortszeit-Stunde aus deinen letzten vier Wochen, mit einem robusten Median-±-MAD-Band darum. Nur Tage mit ausreichend aufgezeichneten Details zählen, und es wird kein Vergleich gezeigt, bevor mindestens vierzehn davon vorliegen.",
"caveat": "Ein Vergleich mit deiner eigenen jüngeren Vergangenheit, kein Ziel. Er sagt, wo der heutige Tag zu dieser Stunde gegenüber deinem üblichen Muster steht — und nichts darüber, ob dieses Muster für dich das richtige ist."
}
},
"scores": {
@@ -3673,6 +3763,13 @@
"caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage. Das schattierte Band wird breiter und zeigt, wie schnell die Unsicherheit wächst.",
"insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist.",
"daysNeeded": "Noch etwa {count} Tage Erfassung nötig, bis eine Projektion erscheinen kann."
},
"sameTime": {
"sectionTitle": "Heute bisher",
"sectionSubtitle": "Deine laufenden Summen im Vergleich zu dem, was zu dieser Stunde für dich üblich ist",
"learning": "Lernt deinen üblichen Tag — {count} von {target} Tagen",
"framing": "Üblich bis {time}: {typical} {unit}",
"comparisonLabel": "Dein üblicher Tag"
}
},
"cardioFitness": {
@@ -3858,17 +3955,6 @@
"forwardHolding": "Im Moment ist alles stabil."
}
},
"tension": {
"label": "Ehrliche Lesung: {positive} sieht gut aus, aber {negative} fällt ab — das Komposit landet bei {band}.",
"contributor": {
"green": "grün",
"yellow": "gelb",
"red": "rot"
}
},
"streak": {
"returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei."
},
"updatedTodayAt": "Heute aktualisiert, {time}",
"updatedYesterday": "Gestern aktualisiert",
"updatedOn": "Aktualisiert am {date}",
@@ -4499,7 +4585,7 @@
},
"fullBackup": {
"title": "Voll-Backup",
"description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe und Workouts in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
"description": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei. Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
"scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
"encryptToggle": "Mit einer Passphrase verschlüsseln",
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
@@ -5148,6 +5234,11 @@
"withingsFullSyncTitle": "Vollsynchronisierung?",
"withingsFullSyncDescription": "Alle verfügbaren Withings-Daten werden vollständig synchronisiert. Das kann je nach Historie etwas dauern.",
"withingsSyncResult": "{count} Messwerte synchronisiert",
"syncOutcome": {
"empty": "Nichts Neues zu importieren. Alles ist aktuell.",
"failed": "Es wurde nichts gespeichert. Der nächste Lauf versucht es erneut.",
"partial": "{count} Messwerte synchronisiert. Ein Teil der Daten konnte nicht gespeichert werden; der nächste Lauf versucht es erneut."
},
"withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert",
"withingsSyncFailed": "Sync fehlgeschlagen",
"withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.",
@@ -5293,6 +5384,7 @@
"connected": "Verbunden",
"errorReconnect": "Fehler — neu verbinden",
"warningServerError": "Sync fehlgeschlagen",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "Pausiert — manuell wieder verbinden",
"notConnected": "Nicht verbunden",
"justNow": "gerade eben",
@@ -7873,6 +7965,9 @@
"saveSelected": "{count} ausgewählte speichern",
"discardAll": "Alle verwerfen",
"savedToast": "{count} Laborwerte gespeichert",
"savedPartialToast": "{count} Laborwerte gespeichert, {skipped} als Dubletten übersprungen",
"savedNothingToast": "Nichts gespeichert: {skipped} Werte waren bereits erfasst",
"savedEmptyToast": "Nichts zu speichern",
"noRows": "In diesem Befund konnten keine Werte gefunden werden.",
"nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.",
"saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.",
@@ -8461,6 +8556,61 @@
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut."
},
"records": {
"aiInclusion": {
"cardTitle": "KI-Zugriff auf das Profil",
"cardDescription": "Lege fest, welche Teile deines Gesundheitsprofils Coach und umfassende Briefings verwenden dürfen.",
"sectionAboutMe": "Über mich",
"sectionConditions": "Chronische Erkrankungen",
"sectionAllergies": "Allergien und Unverträglichkeiten",
"sectionCoachFocus": "Coach-Schwerpunkt",
"sectionFamilyHistory": "Familienanamnese",
"sectionSmoking": "Rauchstatus",
"sectionAlcohol": "Alkoholkonsummuster",
"sectionShiftSchedule": "Arbeitszeitmodell",
"save": "Zugriff speichern",
"savedToast": "KI-Zugriff auf das Profil gespeichert",
"saveError": "KI-Zugriff auf das Profil konnte nicht gespeichert werden",
"loadError": "KI-Zugriff auf das Profil konnte nicht geladen werden"
},
"profileFacts": {
"cardTitle": "Lebensstil-Kontext",
"cardDescription": "Erfasse aktuelle Angaben, die die Einordnung deiner Gesundheitswerte verändern.",
"smokingLabel": "Rauchstatus",
"alcoholLabel": "Alkoholkonsummuster",
"shiftLabel": "Arbeitszeitmodell",
"notRecorded": "Nicht erfasst",
"unreadable": "Gespeicherter Wert konnte nicht gelesen werden",
"save": "Speichern",
"correct": "Korrigieren",
"savedToast": "Lebensstil-Kontext gespeichert",
"saveError": "Lebensstil-Kontext konnte nicht gespeichert werden",
"loadError": "Lebensstil-Kontext konnte nicht geladen werden",
"remove": "{kind} entfernen",
"removeConfirmTitle": "{kind} entfernen?",
"removeConfirmDescription": "Der aktuelle Wert gilt danach als nicht erfasst. Der datierte Verlauf bleibt erhalten.",
"removedToast": "{kind} aus dem aktuellen Lebensstil-Kontext entfernt",
"removeError": "{kind} konnte nicht entfernt werden",
"historyTitle": "Frühere Werte",
"to": "bis",
"values": {
"SMOKING_STATUS": {
"NEVER": "Nie geraucht",
"FORMER": "Früher geraucht",
"CURRENT": "Raucht derzeit"
},
"ALCOHOL_PATTERN": {
"NONE": "Kein Alkohol",
"OCCASIONAL": "Gelegentlich",
"WEEKLY": "Wöchentlich",
"MOST_DAYS": "An den meisten Tagen"
},
"SHIFT_SCHEDULE": {
"NONE": "Keine Schichtarbeit",
"FIXED_SHIFT": "Feste Schicht",
"ROTATING": "Wechselschicht"
}
}
},
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
"allergies": {
"cardTitle": "Allergien & Unverträglichkeiten",
@@ -8819,6 +8969,8 @@
"description": "Beschreibung",
"descriptionPlaceholder": "Was hier gemessen wird (optional)",
"targetHint": "Der Zielbereich ist optional und wird als Band im Diagramm dargestellt.",
"correlationEnabled": "Für Korrelationssuche verwenden",
"correlationEnabledHint": "Fügt diese Metrik als begrenzten täglichen Verhaltenskanal hinzu. Standardmäßig ist dies ausgeschaltet.",
"requiredError": "Name und Einheit sind erforderlich",
"rangeOrderError": "Der untere Zielwert darf den oberen nicht überschreiten",
"save": "Speichern",
@@ -8979,6 +9131,16 @@
"notDetected": "keinen unregelmäßigen Rhythmus",
"inconclusive": "ein nicht eindeutiges Ergebnis"
}
},
"sameTimeBaseline": {
"title": {
"below": "Hinter deinem üblichen Tempo",
"above": "Vor deinem üblichen Tempo"
},
"body": {
"below": "Bis {time} stehst du bei {today} Schritten. An einem üblichen Tag sind es um diese Zeit etwa {typical}.",
"above": "Bis {time} stehst du bei {today} Schritten — mehr als die {typical}, die um diese Zeit für dich üblich sind."
}
}
},
"action": {
@@ -8990,7 +9152,8 @@
"checkinLetGo": "Loslassen",
"viewMilestone": "Ansehen",
"viewPulse": "Puls ansehen",
"viewEcg": "Aufzeichnung ansehen"
"viewEcg": "Aufzeichnung ansehen",
"viewSteps": "Schritte ansehen"
},
"today": {
"scoreLabel": "Gesundheitsscore",
@@ -9040,6 +9203,7 @@
}
},
"reportSelection": {
"leafAnamnesis": "Gesundheitsprofil",
"groupIdentity": "Stammdaten",
"groupVitals": "Vitalwerte",
"groupCardio": "Herz & Kreislauf",
+212 -48
View File
@@ -124,6 +124,15 @@
},
"doctorReport": {
"title": "Health Report",
"anamnesisTitle": "Health profile",
"anamnesisNotRecorded": "Not recorded",
"anamnesisUnreadable": "Stored value could not be read",
"anamnesisConditions": "Chronic conditions",
"anamnesisSmoking": "Smoking status",
"anamnesisAlcohol": "Alcohol pattern",
"anamnesisShiftSchedule": "Work schedule",
"anamnesisColFact": "Profile item",
"anamnesisColValue": "Recorded value",
"subtitle": "HealthLog — Personal Health Report",
"patient": "Patient",
"dateOfBirth": "Date of birth",
@@ -396,10 +405,6 @@
},
"welcomeBack": "{greeting}, welcome back.",
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Doses today"
},
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
"weight": "Weight",
@@ -452,11 +457,22 @@
"compliance7d": "Adherence (7 days)",
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"scoreRingsTitle": "Score rings",
"scoreRingsDescription": "Up to three additional rings next to the health score — for example readiness, recovery, sleep or adherence. Drag to reorder; the health score leads by default.",
"heroItemsTitle": "Today highlights",
"heroItemsDescription": "Choose which item types can appear in Today highlights.",
"heroItemsNotificationNote": "Notification settings are managed separately.",
"heroItems": {
"coach_checkin": "Coach check-ins",
"dose_window": "Medication doses",
"preventive_care": "Preventive care",
"sync_issue": "Sync issues",
"milestone": "Milestones",
"ecg_new_recording": "New ECG recordings",
"tension_window": "Tension windows",
"same_time_baseline": "Same-time activity"
},
"layoutReset": "Reset to defaults",
"layoutResetTitle": "Reset the dashboard layout?",
"layoutResetBody": "Your tile order, the tiles you hid and the rings you chose all go back to defaults. The arrangement is not stored anywhere else.",
"layoutResetBody": "Your tile layout and Today highlight choices return to their defaults.",
"layoutResetConfirm": "Reset layout",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
@@ -773,6 +789,8 @@
"hydrated": "hydrated",
"walked": "walked",
"alcohol": "alcohol",
"caffeine": "caffeine",
"nicotine": "nicotine",
"family": "family",
"friends": "friends",
"party": "party",
@@ -2081,7 +2099,6 @@
},
"comparison": {
"toggleLabel": "Compare to",
"toggleHint": "Show the same period from last year alongside today's chart.",
"baseline": {
"none": "None",
"lastMonth": "Last month",
@@ -2101,6 +2118,15 @@
"notAvailableForCompliance": "Comparison N/A for adherence"
},
"insights": {
"pattern": {
"dismiss": "Dismiss",
"dismissed": "This pattern is dismissed until its evidence changes.",
"restore": "Show again",
"dismissError": "Could not dismiss this pattern.",
"restoreError": "Could not restore this pattern.",
"loadError": "Patterns could not be loaded.",
"empty": "No current pattern cleared the evidence threshold."
},
"correlation": {
"direction": {
"higher": "higher",
@@ -2226,34 +2252,86 @@
"heroActionAskCoach": "Ask the coach",
"healthScore": {
"label": "Health Score",
"progressAria": "Health Score progress",
"deltaVsLastWeek": "{delta} vs last week",
"deltaUnavailable": "No history yet — check back next week.",
"restModePaused": "Paused during illness — your score isn't being judged today.",
"componentBp": "BP",
"componentWeight": "Weight",
"componentMood": "Mood",
"componentCompliance": "Adherence",
"deltaExplainer": {
"title": "How the delta is computed",
"description": "Change vs last week: {delta}",
"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."
"algorithmChanged": "The score now uses published cardiometabolic reference ranges; weight left because its old target came from height, daily mood variability and medication adherence are actions rather than health states, and resting heart rate and HRV have no universal target. Weight progress remains below as personal context, the other signals remain on their own cards, and a new number does not mean your health changed.",
"dismissAlgorithmChanged": "Dismiss score method note",
"band": {
"green": "In the reference range",
"yellow": "Some pillars need attention",
"red": "One or more pillars are outside the reference range"
},
"provenance": {
"toggle": "Driven by",
"mixedBanner": "Some components blend multiple sources. The breakdown shows the most recent value per component.",
"footnote": "Weights are rescaled to the data that's actually available.",
"asOfLabel": "as of {date}",
"provisional": "Provisional — only {count} of {total} inputs available",
"provisionalBadge": "Provisional",
"sourceAria": "Source: {source}",
"sources": {
"manual": "Manual",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mixed",
"none": "No data"
}
"method": "Equal-weight average of eligible pillars. The lowest pillar sets the overall band.",
"insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.",
"composition": "Included: {pillars}",
"bandSetter": "Overall band set by {pillar}",
"versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.",
"methodVersion": "Method version {version}.",
"none": "none",
"delta": "{delta} vs the previous comparable window",
"deltaReason": {
"algorithm_changed": "No comparison because the score method changed.",
"composition_changed": "No comparison because the included pillars changed.",
"first_eligibility_window": "No comparison because this is the first eligible window.",
"below_noise_floor": "Change is within the combined measurement noise.",
"no_previous_window": "No comparable earlier window is available.",
"no_current_score": "No comparison because the current window is not eligible."
},
"restMode": "Rest Mode active since {since}. The score is shown for context and is not penalised.",
"pillar": {
"bloodPressure": "Blood pressure",
"glycaemia": "Glycaemia",
"activity": "Activity",
"sleep": "Sleep",
"adiposity": "Adiposity",
"wellbeing": "Wellbeing",
"fitness": "Measured fitness",
"lipids": "Lipids"
},
"observed": "Observed: {value}",
"reference": "Reference: {value}",
"fastingObserved": "Fasting glucose: {value} {unit}",
"fastingReference": "{low} to {high} {unit} fasting",
"personalReference": "Your target: {value}",
"source": "Source: {source}, as of {date}",
"observedActivity": "{value} steps per day",
"observedSleep": "{value} minutes per night",
"observedAdiposity": "Waist-to-height ratio {value}",
"observedFitness": "{value} {unit}, measured",
"referenceActivity": "Benefit plateaus by {high} steps per day",
"referenceSleep": "At least {low} minutes per night; longer sleep is not penalised",
"referenceAdiposity": "Waist-to-height ratio below {high}",
"referenceFitness": "{low} to {high} {unit}, age and sex reference band",
"referenceHba1c": "HbA1c {low} to {high}%",
"referenceDetail": "{label} · {source} · {kind} · {low} to {high}",
"openBound": "open",
"laboratoryReferenceSource": "Reporting laboratory reference ranges",
"personalTargetSource": "Personal target",
"referenceKind": {
"clinical": "Clinical threshold",
"population": "Population percentile",
"guideline": "Guideline band"
},
"reason": {
"readFailed": "Data could not be loaded",
"notTracked": "Not tracked",
"missingProfile": "Age or reference profile is missing",
"missingHeight": "Height is missing",
"incompletePanel": "A recent complete panel with reference ranges is needed",
"crisis": "Safety guidance is shown instead of a score",
"insufficientHistory": "Not enough recent eligible data",
"unavailable": "This pillar is not currently eligible"
},
"pillarInsufficient": "No pillar score: {reason}",
"tension": "Readiness signals differ. Supportive: {positive}. Under strain: {negative}.",
"returnToBand": "{metric} has been back inside your usual range for {days} days.",
"weightGoal": {
"title": "Personal weight goal",
"value": "{current} {unit} now. Goal {min} to {max} {unit}. Distance to goal: {distance} {unit}.",
"delta": "Distance to goal changed by {delta} {unit} since the prior reading.",
"noDelta": "No earlier weight reading is available for comparison.",
"no_personal_goal": "Set a personal weight goal to see progress here.",
"weight_not_tracked": "Log a weight reading to see progress toward your goal.",
"read_failed": "Weight data could not be loaded. Try again.",
"notScored": "Weight-goal progress is personal context and does not affect the Health Score."
}
},
"suggestedPrompts": {
@@ -2856,6 +2934,14 @@
"scoresBack": "Back to insights",
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result. HealthLog shows the trace and the recording — it does not interpret the waveform."
},
"units": {
"steps": "steps",
"flights": "flights",
"falls": "falls",
"years": "years",
"breathingEvents": "events",
"respiratoryRate": "breaths/min"
},
"sleep": {
"title": "Sleep",
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
@@ -3640,6 +3726,10 @@
"TRAJECTORY": {
"method": "A least-squares line fitted to your recent daily values and projected forward over a short horizon, with the textbook prediction interval that widens the further it reaches. Computed only when the recent trend is strong enough and your history is long and current enough; otherwise no line is drawn.",
"caveat": "A projection of your own recent trend — not a prediction, a target, or a clinical forecast. It holds only if the recent pattern continues; the shaded band shows how quickly that certainty fades."
},
"SAME_TIME_BASELINE": {
"method": "Your running total for today set against the median of the same local hour across your last four weeks, with a robust median ± MAD band around it. Only days with enough recorded detail count towards it, and no comparison is shown until at least fourteen of them are in hand.",
"caveat": "A comparison with your own recent history, not a target. It says where today sits against your usual pattern at this hour and nothing about whether that pattern is right for you."
}
},
"scores": {
@@ -3673,6 +3763,13 @@
"caveat": "A projection of your own recent trend, not a prediction. The shaded band widens to show how quickly the uncertainty grows.",
"insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough.",
"daysNeeded": "About {count} more days of tracking needed before a projection can appear."
},
"sameTime": {
"sectionTitle": "Today so far",
"sectionSubtitle": "Your running totals against what is usual for you at this hour",
"learning": "Learning your usual day — {count} of {target} days",
"framing": "Typical by {time}: {typical} {unit}",
"comparisonLabel": "Your typical day"
}
},
"cardioFitness": {
@@ -3858,17 +3955,6 @@
"forwardHolding": "Everything's holding steady for now."
}
},
"tension": {
"label": "Honest read: {positive} looks good, but {negative} is off — the composite lands {band}.",
"contributor": {
"green": "green",
"yellow": "yellow",
"red": "red"
}
},
"streak": {
"returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed."
},
"updatedTodayAt": "Updated today, {time}",
"updatedYesterday": "Updated yesterday",
"updatedOn": "Updated on {date}",
@@ -4499,7 +4585,7 @@
},
"fullBackup": {
"title": "Full backup",
"description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients and workouts in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
"description": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file. Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
"scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.",
"encryptToggle": "Encrypt with a passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
@@ -5148,6 +5234,11 @@
"withingsFullSyncTitle": "Full synchronization?",
"withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.",
"withingsSyncResult": "{count} measurements synchronized",
"syncOutcome": {
"empty": "Nothing new to import. Everything is up to date.",
"failed": "Nothing was saved. The next run retries it.",
"partial": "{count} measurements synchronized. Some data could not be saved; the next run retries it."
},
"withingsFullSyncResult": "{count} measurements fully synchronized",
"withingsSyncFailed": "Sync failed",
"withingsOauthConnected": "Withings connected — the first sync starts in the background.",
@@ -5293,6 +5384,7 @@
"connected": "Connected",
"errorReconnect": "Error — reconnect",
"warningServerError": "Sync failing",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "Paused — reconnect manually",
"notConnected": "Not connected",
"justNow": "just now",
@@ -7873,6 +7965,9 @@
"saveSelected": "Save {count} selected",
"discardAll": "Discard all",
"savedToast": "{count} lab results saved",
"savedPartialToast": "{count} lab results saved, {skipped} skipped as duplicates",
"savedNothingToast": "Nothing saved: {skipped} readings were already recorded",
"savedEmptyToast": "Nothing to save",
"noRows": "No readings could be found in this report.",
"nothingToSave": "Select at least one reading to save.",
"saveFailed": "Couldn't save the readings. Please try again.",
@@ -8461,6 +8556,61 @@
"historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again."
},
"records": {
"aiInclusion": {
"cardTitle": "AI profile access",
"cardDescription": "Choose which parts of your health profile may be used by Coach and comprehensive briefings.",
"sectionAboutMe": "About me",
"sectionConditions": "Chronic conditions",
"sectionAllergies": "Allergies and intolerances",
"sectionCoachFocus": "Coach focus",
"sectionFamilyHistory": "Family history",
"sectionSmoking": "Smoking status",
"sectionAlcohol": "Alcohol pattern",
"sectionShiftSchedule": "Work schedule",
"save": "Save access",
"savedToast": "AI profile access saved",
"saveError": "Could not save AI profile access",
"loadError": "Could not load AI profile access"
},
"profileFacts": {
"cardTitle": "Lifestyle context",
"cardDescription": "Record the current facts that change how your health readings should be interpreted.",
"smokingLabel": "Smoking status",
"alcoholLabel": "Alcohol pattern",
"shiftLabel": "Work schedule",
"notRecorded": "Not recorded",
"unreadable": "Stored value could not be read",
"save": "Save",
"correct": "Correct",
"savedToast": "Lifestyle context saved",
"saveError": "Could not save lifestyle context",
"loadError": "Could not load lifestyle context",
"remove": "Remove {kind}",
"removeConfirmTitle": "Remove {kind}?",
"removeConfirmDescription": "The current value will become not recorded. Its dated history will be retained.",
"removedToast": "{kind} removed from current lifestyle context",
"removeError": "Could not remove {kind}",
"historyTitle": "Previous values",
"to": "to",
"values": {
"SMOKING_STATUS": {
"NEVER": "Never smoked",
"FORMER": "Former smoker",
"CURRENT": "Currently smokes"
},
"ALCOHOL_PATTERN": {
"NONE": "None",
"OCCASIONAL": "Occasional",
"WEEKLY": "Weekly",
"MOST_DAYS": "Most days"
},
"SHIFT_SCHEDULE": {
"NONE": "No shift work",
"FIXED_SHIFT": "Fixed shift",
"ROTATING": "Rotating shifts"
}
}
},
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
"allergies": {
"cardTitle": "Allergies & intolerances",
@@ -8819,6 +8969,8 @@
"description": "Description",
"descriptionPlaceholder": "What this measures (optional)",
"targetHint": "The target range is optional and is drawn as a band on the chart.",
"correlationEnabled": "Use in correlation discovery",
"correlationEnabledHint": "Adds this metric as a bounded daily behaviour channel. It remains off by default.",
"requiredError": "Name and unit are required",
"rangeOrderError": "Target low must not exceed target high",
"save": "Save",
@@ -8979,6 +9131,16 @@
"notDetected": "no irregular rhythm",
"inconclusive": "an inconclusive result"
}
},
"sameTimeBaseline": {
"title": {
"below": "Behind your usual pace",
"above": "Ahead of your usual pace"
},
"body": {
"below": "By {time} you are at {today} steps. On a usual day you are around {typical} by now.",
"above": "By {time} you are at {today} steps, ahead of the {typical} that is usual for you by now."
}
}
},
"action": {
@@ -8990,7 +9152,8 @@
"checkinLetGo": "Let it go",
"viewMilestone": "View",
"viewPulse": "View pulse",
"viewEcg": "View recording"
"viewEcg": "View recording",
"viewSteps": "View steps"
},
"today": {
"scoreLabel": "Health score",
@@ -9040,6 +9203,7 @@
}
},
"reportSelection": {
"leafAnamnesis": "Health profile",
"groupIdentity": "Personal details",
"groupVitals": "Vital signs",
"groupCardio": "Heart & circulation",
+212 -48
View File
@@ -124,6 +124,15 @@
},
"doctorReport": {
"title": "Informe de salud",
"anamnesisTitle": "Perfil de salud",
"anamnesisNotRecorded": "No registrado",
"anamnesisUnreadable": "No se pudo leer el valor guardado",
"anamnesisConditions": "Enfermedades crónicas",
"anamnesisSmoking": "Tabaquismo",
"anamnesisAlcohol": "Patrón de consumo de alcohol",
"anamnesisShiftSchedule": "Horario de trabajo",
"anamnesisColFact": "Dato del perfil",
"anamnesisColValue": "Valor registrado",
"subtitle": "HealthLog — Informe personal de salud",
"patient": "Paciente",
"dateOfBirth": "Fecha de nacimiento",
@@ -396,10 +405,6 @@
},
"welcomeBack": "{greeting}, te damos la bienvenida de nuevo.",
"welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dosis de hoy"
},
"greetingSalutation": "Hola, {name}",
"streakLabel": "días seguidos",
"weight": "Peso",
@@ -452,11 +457,22 @@
"compliance7d": "Cumplimiento (7 días)",
"mood": "Estado de ánimo",
"customizeTitle": "Diseño del panel",
"scoreRingsTitle": "Anillos de puntuación",
"scoreRingsDescription": "Hasta tres anillos adicionales junto a la puntuación de salud, por ejemplo preparación, recuperación, sueño o cumplimiento. Arrastra para reordenar; la puntuación de salud va primero de forma predeterminada.",
"heroItemsTitle": "Destacados de hoy",
"heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.",
"heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.",
"heroItems": {
"coach_checkin": "Revisión con Coach",
"dose_window": "Dosis de medicamentos",
"preventive_care": "Atención preventiva",
"sync_issue": "Problemas de sincronización",
"milestone": "Hitos",
"ecg_new_recording": "Nuevos registros de ECG",
"tension_window": "Periodos de tensión",
"same_time_baseline": "Actividad a la misma hora"
},
"layoutReset": "Restablecer valores por defecto",
"layoutResetTitle": "¿Restablecer el diseño del panel?",
"layoutResetBody": "El orden de tus tarjetas, las que ocultaste y los anillos que elegiste vuelven a los valores predeterminados. La disposición no se guarda en ningún otro sitio.",
"layoutResetBody": "El diseño de tus tarjetas y tus opciones para los destacados de hoy vuelven a los valores predeterminados.",
"layoutResetConfirm": "Restablecer diseño",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
@@ -773,6 +789,8 @@
"hydrated": "bebí agua",
"walked": "caminé",
"alcohol": "alcohol",
"caffeine": "cafeína",
"nicotine": "nicotina",
"family": "familia",
"friends": "amigos",
"party": "fiesta",
@@ -2081,7 +2099,6 @@
},
"comparison": {
"toggleLabel": "Comparar con",
"toggleHint": "Compara el periodo actual con el mismo periodo del año anterior.",
"baseline": {
"none": "Desactivado",
"lastMonth": "Mes anterior",
@@ -2101,6 +2118,15 @@
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
},
"insights": {
"pattern": {
"dismiss": "Descartar",
"dismissed": "Este patrón queda descartado hasta que cambie la evidencia.",
"restore": "Mostrar de nuevo",
"dismissError": "No se pudo descartar este patrón.",
"restoreError": "No se pudo restaurar este patrón.",
"loadError": "No se pudieron cargar los patrones.",
"empty": "Ningún patrón actual superó el umbral de evidencia."
},
"correlation": {
"direction": {
"higher": "subir",
@@ -2226,34 +2252,86 @@
"heroActionAskCoach": "Preguntar al coach",
"healthScore": {
"label": "Puntuación de salud",
"progressAria": "Progreso de la puntuación de salud",
"deltaVsLastWeek": "{delta} respecto a la semana anterior",
"deltaUnavailable": "Aún no hay historial — vuelve la semana que viene.",
"restModePaused": "En pausa durante la enfermedad — hoy tu puntuación no se evalúa.",
"componentBp": "PA",
"componentWeight": "Peso",
"componentMood": "Estado de ánimo",
"componentCompliance": "Cumplimiento",
"deltaExplainer": {
"title": "Cómo se calcula el cambio",
"description": "Cambio respecto a la semana pasada: {delta}",
"body": "La puntuación combina PA, peso, ánimo y adherencia. Comparamos la puntuación de hoy con la del mismo momento de la semana anterior. Registra una medición nueva o una nota de ánimo para empujarla hacia arriba."
"algorithmChanged": "La puntuación ahora usa intervalos de referencia cardiometabólicos publicados; el peso ya no se incluye porque el objetivo anterior se derivaba de la estatura en vez de un objetivo personal, la variabilidad diaria del ánimo y la adherencia a la medicación son acciones y no estados de salud, y no existe un objetivo universal para la frecuencia cardiaca en reposo ni la VFC. El progreso de peso sigue apareciendo abajo como contexto personal, las demás señales conservan sus propias tarjetas y una cifra nueva no significa que tu salud haya cambiado.",
"dismissAlgorithmChanged": "Cerrar la nota sobre el método",
"band": {
"green": "Dentro del intervalo de referencia",
"yellow": "Algunos pilares requieren atención",
"red": "Uno o más pilares están fuera del intervalo de referencia"
},
"provenance": {
"toggle": "Compuesto por",
"mixedBanner": "Algunos componentes combinan varias fuentes. El desglose muestra el valor más reciente por componente.",
"footnote": "Los pesos se reescalan según los datos realmente disponibles.",
"asOfLabel": "a fecha de {date}",
"provisional": "Provisional — sólo {count} de {total} entradas disponibles",
"provisionalBadge": "Provisional",
"sourceAria": "Fuente: {source}",
"sources": {
"manual": "Manual",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mixto",
"none": "Sin datos"
}
"method": "Media con el mismo peso para los pilares aptos. El pilar más bajo determina la categoría general.",
"insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.",
"composition": "Incluidos: {pillars}",
"bandSetter": "Categoría general determinada por {pillar}",
"versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.",
"methodVersion": "Versión del método {version}.",
"none": "ninguno",
"delta": "{delta} frente al periodo comparable anterior",
"deltaReason": {
"algorithm_changed": "No hay comparación porque cambió el método de puntuación.",
"composition_changed": "No hay comparación porque cambiaron los pilares incluidos.",
"first_eligibility_window": "No hay comparación porque este es el primer periodo apto.",
"below_noise_floor": "El cambio está dentro de la variación conjunta de las mediciones.",
"no_previous_window": "No hay un periodo anterior comparable.",
"no_current_score": "No hay comparación porque el periodo actual no es apto."
},
"restMode": "Modo de descanso activo desde {since}. La puntuación se muestra como contexto y no se penaliza.",
"pillar": {
"bloodPressure": "Presión arterial",
"glycaemia": "Glucemia",
"activity": "Actividad",
"sleep": "Sueño",
"adiposity": "Adiposidad",
"wellbeing": "Bienestar",
"fitness": "Forma física medida",
"lipids": "Lípidos"
},
"observed": "Observado: {value}",
"reference": "Referencia: {value}",
"fastingObserved": "Glucosa en ayunas: {value} {unit}",
"fastingReference": "{low} a {high} {unit} en ayunas",
"personalReference": "Tu objetivo: {value}",
"source": "Fuente: {source}, a fecha de {date}",
"observedActivity": "{value} pasos al día",
"observedSleep": "{value} minutos por noche",
"observedAdiposity": "Relación cintura-altura {value}",
"observedFitness": "{value} {unit}, medido",
"referenceActivity": "El beneficio se estabiliza en {high} pasos al día",
"referenceSleep": "Al menos {low} minutos por noche; el sueño más largo no se penaliza",
"referenceAdiposity": "Relación cintura-altura inferior a {high}",
"referenceFitness": "{low} a {high} {unit}, intervalo de referencia por edad y sexo",
"referenceHba1c": "HbA1c de {low} a {high} %",
"referenceDetail": "{label} · {source} · {kind} · {low} a {high}",
"openBound": "abierto",
"laboratoryReferenceSource": "Intervalos de referencia del laboratorio",
"personalTargetSource": "Objetivo personal",
"referenceKind": {
"clinical": "Umbral clínico",
"population": "Percentil poblacional",
"guideline": "Intervalo de la guía"
},
"reason": {
"readFailed": "No se pudieron cargar los datos",
"notTracked": "Sin seguimiento",
"missingProfile": "Falta la edad o el perfil de referencia",
"missingHeight": "Falta la altura",
"incompletePanel": "Se necesita un panel reciente completo con intervalos de referencia",
"crisis": "Se muestran indicaciones de seguridad en lugar de una puntuación",
"insufficientHistory": "No hay suficientes datos recientes aptos",
"unavailable": "Este pilar no reúne los requisitos actualmente"
},
"pillarInsufficient": "Sin puntuación del pilar: {reason}",
"tension": "Las señales de preparación difieren. Favorables: {positive}. Con tensión: {negative}.",
"returnToBand": "{metric} lleva {days} días de nuevo dentro de tu intervalo habitual.",
"weightGoal": {
"title": "Objetivo personal de peso",
"value": "Ahora {current} {unit}. Objetivo de {min} a {max} {unit}. Distancia al objetivo: {distance} {unit}.",
"delta": "La distancia al objetivo cambió {delta} {unit} desde la lectura anterior.",
"noDelta": "No hay una lectura de peso anterior para comparar.",
"no_personal_goal": "Define un objetivo personal de peso para ver aquí el progreso.",
"weight_not_tracked": "Registra una lectura de peso para ver el progreso hacia tu objetivo.",
"read_failed": "No se pudieron cargar los datos de peso. Inténtalo de nuevo.",
"notScored": "El progreso del objetivo de peso es contexto personal y no afecta a la puntuación de salud."
}
},
"suggestedPrompts": {
@@ -2856,6 +2934,14 @@
"scoresBack": "Volver a Insights",
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo. HealthLog muestra el trazado y el registro, pero no interpreta la onda."
},
"units": {
"steps": "pasos",
"flights": "pisos",
"falls": "caídas",
"years": "años",
"breathingEvents": "eventos",
"respiratoryRate": "respiraciones/min"
},
"sleep": {
"title": "Sueño",
"description": "Desglose por fases y duración nocturna, tomados de Apple Health.",
@@ -3640,6 +3726,10 @@
"TRAJECTORY": {
"method": "Una recta de mínimos cuadrados ajustada a tus valores diarios recientes y proyectada hacia delante en un horizonte corto, con el intervalo de predicción clásico que se ensancha cuanto más lejos llega. Solo se calcula cuando la tendencia reciente es lo bastante marcada y tu historial es lo bastante largo y actual; de lo contrario no se traza ninguna línea.",
"caveat": "Una proyección de tu propia tendencia reciente, no una predicción, un objetivo ni un pronóstico clínico. Solo se cumple si el patrón reciente continúa; la banda sombreada muestra con qué rapidez se desvanece esa certeza."
},
"SAME_TIME_BASELINE": {
"method": "Tu total acumulado de hoy frente a la mediana de esa misma hora local durante tus últimas cuatro semanas, con una banda robusta de mediana ± MAD alrededor. Solo cuentan los días con suficiente detalle registrado, y no se muestra ninguna comparación hasta tener al menos catorce.",
"caveat": "Una comparación con tu propio historial reciente, no un objetivo. Indica dónde se sitúa hoy frente a tu patrón habitual a esta hora, y nada sobre si ese patrón es el adecuado para ti."
}
},
"scores": {
@@ -3673,6 +3763,13 @@
"caveat": "Una proyección de tu propia tendencia reciente, no una predicción. La banda sombreada se ensancha para mostrar la rapidez con que crece la incertidumbre.",
"insufficient": "Aún no hay una tendencia lo bastante clara para proyectar: sigue registrando y aparecerá una proyección cuando el patrón reciente sea lo bastante marcado.",
"daysNeeded": "Faltan unos {count} días más de registro para que pueda aparecer una proyección."
},
"sameTime": {
"sectionTitle": "Hoy hasta ahora",
"sectionSubtitle": "Tus totales acumulados frente a lo que es habitual en ti a esta hora",
"learning": "Aprendiendo tu día habitual — {count} de {target} días",
"framing": "Habitual a las {time}: {typical} {unit}",
"comparisonLabel": "Tu día habitual"
}
},
"cardioFitness": {
@@ -3858,17 +3955,6 @@
"forwardHolding": "Por ahora todo se mantiene estable."
}
},
"tension": {
"label": "Lectura sincera: {positive} tiene buena pinta, pero {negative} flojea — el conjunto queda en {band}.",
"contributor": {
"green": "verde",
"yellow": "amarillo",
"red": "rojo"
}
},
"streak": {
"returnLabel": "Tu {metric} ha vuelto a tu rango habitual tras {count} días — fuera lo que fuera, ya pasó."
},
"updatedTodayAt": "Actualizado hoy, {time}",
"updatedYesterday": "Actualizado ayer",
"updatedOn": "Actualizado el {date}",
@@ -4499,7 +4585,7 @@
},
"fullBackup": {
"title": "Copia completa",
"description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes y entrenamientos en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
"description": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON. El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
"scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
"encryptToggle": "Cifrar con una contraseña",
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
@@ -5148,6 +5234,11 @@
"withingsFullSyncTitle": "¿Sincronización completa?",
"withingsFullSyncDescription": "Se sincronizarán por completo todos los datos disponibles de Withings. Esto puede tardar un poco según tu historial.",
"withingsSyncResult": "{count} mediciones sincronizadas",
"syncOutcome": {
"empty": "No hay nada nuevo que importar. Todo está al día.",
"failed": "No se guardó nada. El próximo intento lo repetirá.",
"partial": "{count} mediciones sincronizadas. Parte de los datos no se pudo guardar; el próximo intento lo repetirá."
},
"withingsFullSyncResult": "{count} mediciones sincronizadas por completo",
"withingsSyncFailed": "Falló la sincronización",
"withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.",
@@ -5293,6 +5384,7 @@
"connected": "Conectado",
"errorReconnect": "Error — reconectar",
"warningServerError": "Sincronización con errores",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "Pausado — reconectar manualmente",
"notConnected": "No conectado",
"justNow": "ahora mismo",
@@ -7873,6 +7965,9 @@
"saveSelected": "Guardar {count} seleccionados",
"discardAll": "Descartar todo",
"savedToast": "{count} resultados de laboratorio guardados",
"savedPartialToast": "{count} resultados de laboratorio guardados, {skipped} omitidos por duplicados",
"savedNothingToast": "No se guardó nada: {skipped} valores ya estaban registrados",
"savedEmptyToast": "No hay nada que guardar",
"noRows": "No se encontraron valores en este informe.",
"nothingToSave": "Selecciona al menos un valor para guardar.",
"saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.",
@@ -8461,6 +8556,61 @@
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo."
},
"records": {
"aiInclusion": {
"cardTitle": "Acceso de la IA al perfil",
"cardDescription": "Elige qué partes de tu perfil de salud pueden usar el Coach y los informes completos.",
"sectionAboutMe": "Sobre mí",
"sectionConditions": "Enfermedades crónicas",
"sectionAllergies": "Alergias e intolerancias",
"sectionCoachFocus": "Prioridad del Coach",
"sectionFamilyHistory": "Antecedentes familiares",
"sectionSmoking": "Tabaquismo",
"sectionAlcohol": "Patrón de consumo de alcohol",
"sectionShiftSchedule": "Horario de trabajo",
"save": "Guardar acceso",
"savedToast": "Acceso de la IA al perfil guardado",
"saveError": "No se pudo guardar el acceso de la IA al perfil",
"loadError": "No se pudo cargar el acceso de la IA al perfil"
},
"profileFacts": {
"cardTitle": "Contexto del estilo de vida",
"cardDescription": "Registra los datos actuales que cambian la interpretación de tus mediciones de salud.",
"smokingLabel": "Tabaquismo",
"alcoholLabel": "Patrón de consumo de alcohol",
"shiftLabel": "Horario de trabajo",
"notRecorded": "No registrado",
"unreadable": "No se pudo leer el valor guardado",
"save": "Guardar",
"correct": "Corregir",
"savedToast": "Contexto del estilo de vida guardado",
"saveError": "No se pudo guardar el contexto del estilo de vida",
"loadError": "No se pudo cargar el contexto del estilo de vida",
"remove": "Eliminar {kind}",
"removeConfirmTitle": "¿Eliminar {kind}?",
"removeConfirmDescription": "El valor actual pasará a constar como no registrado. Se conservará su historial fechado.",
"removedToast": "{kind} eliminado del contexto actual del estilo de vida",
"removeError": "No se pudo eliminar {kind}",
"historyTitle": "Valores anteriores",
"to": "a",
"values": {
"SMOKING_STATUS": {
"NEVER": "Nunca ha fumado",
"FORMER": "Exfumador",
"CURRENT": "Fuma actualmente"
},
"ALCOHOL_PATTERN": {
"NONE": "Ninguno",
"OCCASIONAL": "Ocasional",
"WEEKLY": "Semanal",
"MOST_DAYS": "La mayoría de los días"
},
"SHIFT_SCHEDULE": {
"NONE": "Sin trabajo por turnos",
"FIXED_SHIFT": "Turno fijo",
"ROTATING": "Turnos rotativos"
}
}
},
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
"allergies": {
"cardTitle": "Alergias e intolerancias",
@@ -8819,6 +8969,8 @@
"description": "Descripción",
"descriptionPlaceholder": "Qué mide esto (opcional)",
"targetHint": "El rango objetivo es opcional y se dibuja como una banda en el gráfico.",
"correlationEnabled": "Usar en el análisis de correlaciones",
"correlationEnabledHint": "Añade esta métrica como canal diario de conducta con un límite. Está desactivado por defecto.",
"requiredError": "El nombre y la unidad son obligatorios",
"rangeOrderError": "El objetivo mínimo no puede superar al máximo",
"save": "Guardar",
@@ -8979,6 +9131,16 @@
"notDetected": "ningún ritmo irregular",
"inconclusive": "un resultado no concluyente"
}
},
"sameTimeBaseline": {
"title": {
"below": "Por detrás de tu ritmo habitual",
"above": "Por delante de tu ritmo habitual"
},
"body": {
"below": "A las {time} llevas {today} pasos. En un día habitual rondas los {typical} a esta hora.",
"above": "A las {time} llevas {today} pasos, por encima de los {typical} habituales a esta hora."
}
}
},
"action": {
@@ -8990,7 +9152,8 @@
"checkinLetGo": "Dejarlo ir",
"viewMilestone": "Ver",
"viewPulse": "Ver pulso",
"viewEcg": "Ver registro"
"viewEcg": "Ver registro",
"viewSteps": "Ver pasos"
},
"today": {
"scoreLabel": "Puntuación de salud",
@@ -9040,6 +9203,7 @@
}
},
"reportSelection": {
"leafAnamnesis": "Perfil de salud",
"groupIdentity": "Datos personales",
"groupVitals": "Signos vitales",
"groupCardio": "Corazón y circulación",
+212 -48
View File
@@ -124,6 +124,15 @@
},
"doctorReport": {
"title": "Rapport de santé",
"anamnesisTitle": "Profil de santé",
"anamnesisNotRecorded": "Non renseigné",
"anamnesisUnreadable": "La valeur enregistrée n'a pas pu être lue",
"anamnesisConditions": "Maladies chroniques",
"anamnesisSmoking": "Tabagisme",
"anamnesisAlcohol": "Habitudes de consommation d'alcool",
"anamnesisShiftSchedule": "Organisation du travail",
"anamnesisColFact": "Élément du profil",
"anamnesisColValue": "Valeur enregistrée",
"subtitle": "HealthLog — Rapport personnel de santé",
"patient": "Patient",
"dateOfBirth": "Date de naissance",
@@ -396,10 +405,6 @@
},
"welcomeBack": "{greeting}, content de te revoir.",
"welcomeBackWithName": "{greeting} {name}, content de te revoir.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Doses du jour"
},
"greetingSalutation": "Bonjour, {name}",
"streakLabel": "jours d’affilée",
"weight": "Poids",
@@ -452,11 +457,22 @@
"compliance7d": "Observance (7 jours)",
"mood": "Humeur",
"customizeTitle": "Mise en page du tableau",
"scoreRingsTitle": "Anneaux de score",
"scoreRingsDescription": "Jusqu’à trois anneaux supplémentaires à côté du score de santé — par exemple préparation, récupération, sommeil ou observance. Glissez pour réordonner ; le score de santé est placé en premier par défaut.",
"heroItemsTitle": "Temps forts du jour",
"heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.",
"heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.",
"heroItems": {
"coach_checkin": "Bilan avec Coach",
"dose_window": "Doses de médicaments",
"preventive_care": "Prévention",
"sync_issue": "Problèmes de synchronisation",
"milestone": "Étapes franchies",
"ecg_new_recording": "Nouveaux enregistrements ECG",
"tension_window": "Périodes de tension",
"same_time_baseline": "Activité à la même heure"
},
"layoutReset": "Rétablir les valeurs par défaut",
"layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?",
"layoutResetBody": "L’ordre de vos tuiles, celles que vous avez masquées et les anneaux choisis reviennent aux valeurs par défaut. Cette disposition n’est conservée nulle part ailleurs.",
"layoutResetBody": "La disposition de tes tuiles et tes choix pour les temps forts du jour reviennent aux valeurs par défaut.",
"layoutResetConfirm": "Réinitialiser la disposition",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
@@ -773,6 +789,8 @@
"hydrated": "bu de l'eau",
"walked": "marché",
"alcohol": "alcool",
"caffeine": "caféine",
"nicotine": "nicotine",
"family": "famille",
"friends": "amis",
"party": "fête",
@@ -2081,7 +2099,6 @@
},
"comparison": {
"toggleLabel": "Comparer avec",
"toggleHint": "Compare la période actuelle avec la même période de l’année précédente.",
"baseline": {
"none": "Désactivé",
"lastMonth": "Mois précédent",
@@ -2101,6 +2118,15 @@
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
},
"insights": {
"pattern": {
"dismiss": "Ignorer",
"dismissed": "Ce schéma reste ignoré jusqu’à ce que les données changent.",
"restore": "Afficher à nouveau",
"dismissError": "Impossible d’ignorer ce schéma.",
"restoreError": "Impossible de restaurer ce schéma.",
"loadError": "Impossible de charger les schémas.",
"empty": "Aucun schéma actuel n’a franchi le seuil de preuve."
},
"correlation": {
"direction": {
"higher": "augmenter",
@@ -2226,34 +2252,86 @@
"heroActionAskCoach": "Demander au coach",
"healthScore": {
"label": "Score de santé",
"progressAria": "Progression du score de santé",
"deltaVsLastWeek": "{delta} par rapport à la semaine précédente",
"deltaUnavailable": "Pas encore d’historique — reviens la semaine prochaine.",
"restModePaused": "En pause pendant la maladie — ton score n'est pas évalué aujourd'hui.",
"componentBp": "TA",
"componentWeight": "Poids",
"componentMood": "Humeur",
"componentCompliance": "Observance",
"deltaExplainer": {
"title": "Comment l'écart est calculé",
"description": "Écart par rapport à la semaine dernière : {delta}",
"body": "Le score combine TA, poids, humeur et observance. Nous comparons le score d'aujourd'hui à celui de la semaine précédente à la même période. Enregistre une nouvelle mesure ou une note d'humeur pour le faire remonter."
"algorithmChanged": "Le score utilise désormais des plages de référence cardiométaboliques publiées; le poids n'est plus inclus car son ancienne cible provenait de la taille plutôt que d'un objectif personnel, les variations quotidiennes de l'humeur et l'observance médicamenteuse sont des actions plutôt que des états de santé, et la fréquence cardiaque au repos comme la VFC n'ont pas de cible universelle. La progression du poids reste affichée ci-dessous comme contexte personnel, les autres signaux conservent leurs propres cartes et un nouveau chiffre ne signifie pas que ta santé a changé.",
"dismissAlgorithmChanged": "Fermer la note sur la méthode",
"band": {
"green": "Dans la plage de référence",
"yellow": "Certains piliers demandent une attention",
"red": "Un ou plusieurs piliers sont hors de la plage de référence"
},
"provenance": {
"toggle": "Composé de",
"mixedBanner": "Certains composants combinent plusieurs sources. La ventilation affiche la valeur la plus récente par composant.",
"footnote": "Les pondérations sont recalibrées en fonction des données disponibles.",
"asOfLabel": "au {date}",
"provisional": "Provisoire — seulement {count} entrée(s) sur {total} disponible(s)",
"provisionalBadge": "Provisoire",
"sourceAria": "Source : {source}",
"sources": {
"manual": "Manuelle",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mixte",
"none": "Aucune donnée"
}
"method": "Moyenne à poids égal des piliers admissibles. Le pilier le plus bas fixe la catégorie globale.",
"insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.",
"composition": "Inclus : {pillars}",
"bandSetter": "Catégorie globale fixée par {pillar}",
"versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.",
"methodVersion": "Version de la méthode {version}.",
"none": "aucun",
"delta": "{delta} par rapport à la période comparable précédente",
"deltaReason": {
"algorithm_changed": "Aucune comparaison car la méthode de calcul a changé.",
"composition_changed": "Aucune comparaison car les piliers inclus ont changé.",
"first_eligibility_window": "Aucune comparaison car il s'agit de la première période admissible.",
"below_noise_floor": "La variation reste dans la marge de fluctuation combinée des mesures.",
"no_previous_window": "Aucune période comparable antérieure n'est disponible.",
"no_current_score": "Aucune comparaison car la période actuelle n'est pas admissible."
},
"restMode": "Mode repos actif depuis {since}. Le score est affiché pour contexte et n'est pas pénalisé.",
"pillar": {
"bloodPressure": "Tension artérielle",
"glycaemia": "Glycémie",
"activity": "Activité",
"sleep": "Sommeil",
"adiposity": "Adiposité",
"wellbeing": "Bien-être",
"fitness": "Condition physique mesurée",
"lipids": "Lipides"
},
"observed": "Observé : {value}",
"reference": "Référence : {value}",
"fastingObserved": "Glycémie à jeun : {value} {unit}",
"fastingReference": "{low} à {high} {unit} à jeun",
"personalReference": "Votre objectif : {value}",
"source": "Source : {source}, au {date}",
"observedActivity": "{value} pas par jour",
"observedSleep": "{value} minutes par nuit",
"observedAdiposity": "Rapport taille-hauteur {value}",
"observedFitness": "{value} {unit}, mesuré",
"referenceActivity": "Le bénéfice se stabilise à {high} pas par jour",
"referenceSleep": "Au moins {low} minutes par nuit ; un sommeil plus long n'est pas pénalisé",
"referenceAdiposity": "Rapport taille-hauteur inférieur à {high}",
"referenceFitness": "{low} à {high} {unit}, plage de référence selon l'âge et le sexe",
"referenceHba1c": "HbA1c de {low} à {high} %",
"referenceDetail": "{label} · {source} · {kind} · {low} à {high}",
"openBound": "ouvert",
"laboratoryReferenceSource": "Plages de référence du laboratoire",
"personalTargetSource": "Objectif personnel",
"referenceKind": {
"clinical": "Seuil clinique",
"population": "Percentile de population",
"guideline": "Plage recommandée"
},
"reason": {
"readFailed": "Les données n'ont pas pu être chargées",
"notTracked": "Non suivi",
"missingProfile": "L'âge ou le profil de référence manque",
"missingHeight": "La taille manque",
"incompletePanel": "Un bilan récent complet avec plages de référence est requis",
"crisis": "Des consignes de sécurité sont affichées à la place d'un score",
"insufficientHistory": "Pas assez de données récentes admissibles",
"unavailable": "Ce pilier n'est pas admissible actuellement"
},
"pillarInsufficient": "Aucun score pour ce pilier : {reason}",
"tension": "Les signaux de préparation diffèrent. Favorables : {positive}. Sous tension : {negative}.",
"returnToBand": "{metric} est revenu dans ta plage habituelle depuis {days} jours.",
"weightGoal": {
"title": "Objectif de poids personnel",
"value": "{current} {unit} actuellement. Objectif de {min} à {max} {unit}. Écart à l'objectif : {distance} {unit}.",
"delta": "L'écart à l'objectif a changé de {delta} {unit} depuis la mesure précédente.",
"noDelta": "Aucune mesure de poids antérieure n'est disponible pour la comparaison.",
"no_personal_goal": "Définis un objectif de poids personnel pour suivre ta progression ici.",
"weight_not_tracked": "Enregistre ton poids pour suivre ta progression vers ton objectif.",
"read_failed": "Les données de poids n'ont pas pu être chargées. Réessaie.",
"notScored": "La progression vers l'objectif de poids est un contexte personnel et ne modifie pas le score de santé."
}
},
"suggestedPrompts": {
@@ -2856,6 +2934,14 @@
"scoresBack": "Retour aux Insights",
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil. HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
},
"units": {
"steps": "pas",
"flights": "étages",
"falls": "chutes",
"years": "ans",
"breathingEvents": "événements",
"respiratoryRate": "respirations/min"
},
"sleep": {
"title": "Sommeil",
"description": "Répartition des phases et durée nocturne, issues d’Apple Health.",
@@ -3640,6 +3726,10 @@
"TRAJECTORY": {
"method": "Une droite des moindres carrés ajustée à vos valeurs journalières récentes et projetée sur un court horizon, avec l’intervalle de prédiction classique qui s’élargit à mesure qu’il s’éloigne. Calculée uniquement lorsque la tendance récente est suffisamment marquée et que votre historique est assez long et récent ; sinon aucune ligne n’est tracée.",
"caveat": "Une projection de votre propre tendance récente — ni une prédiction, ni un objectif, ni un pronostic clinique. Elle ne tient que si le schéma récent se poursuit ; la bande ombrée montre la vitesse à laquelle cette certitude s’estompe."
},
"SAME_TIME_BASELINE": {
"method": "Votre total cumulé du jour comparé à la médiane de cette même heure locale sur vos quatre dernières semaines, avec une bande robuste médiane ± MAD autour. Seuls les jours suffisamment détaillés comptent, et aucune comparaison n'apparaît avant d'en avoir au moins quatorze.",
"caveat": "Une comparaison avec votre propre historique récent, pas un objectif. Elle dit où se situe aujourd'hui par rapport à votre habitude à cette heure, et rien sur le fait que cette habitude vous convienne."
}
},
"scores": {
@@ -3673,6 +3763,13 @@
"caveat": "Une projection de votre propre tendance récente, pas une prédiction. La bande ombrée s’élargit pour montrer la vitesse à laquelle l’incertitude augmente.",
"insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué.",
"daysNeeded": "Encore environ {count} jours d’enregistrement nécessaires avant qu’une projection puisse apparaître."
},
"sameTime": {
"sectionTitle": "Aujourd'hui jusqu'ici",
"sectionSubtitle": "Vos totaux cumulés face à ce qui est habituel chez vous à cette heure",
"learning": "Apprentissage de votre journée habituelle — {count} jours sur {target}",
"framing": "Habituel à {time} : {typical} {unit}",
"comparisonLabel": "Votre journée habituelle"
}
},
"cardioFitness": {
@@ -3858,17 +3955,6 @@
"forwardHolding": "Pour l'instant, tout reste stable."
}
},
"tension": {
"label": "Lecture honnête : {positive} se présente bien, mais {negative} décroche — l'ensemble se situe à {band}.",
"contributor": {
"green": "vert",
"yellow": "jaune",
"red": "rouge"
}
},
"streak": {
"returnLabel": "Ton {metric} est de nouveau dans ta plage habituelle après {count} jours — quoi que c'était, c'est passé."
},
"updatedTodayAt": "Mis à jour aujourd'hui, {time}",
"updatedYesterday": "Mis à jour hier",
"updatedOn": "Mis à jour le {date}",
@@ -4499,7 +4585,7 @@
},
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments et séances dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
"description": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON. Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
"scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
"encryptToggle": "Chiffrer avec une phrase secrète",
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
@@ -5148,6 +5234,11 @@
"withingsFullSyncTitle": "Synchronisation complète ?",
"withingsFullSyncDescription": "Toutes les données Withings disponibles seront entièrement synchronisées. Cela peut prendre un certain temps selon votre historique.",
"withingsSyncResult": "{count} mesures synchronisées",
"syncOutcome": {
"empty": "Rien de nouveau à importer. Tout est à jour.",
"failed": "Rien n'a été enregistré. La prochaine synchronisation réessaiera.",
"partial": "{count} mesures synchronisées. Une partie des données n'a pas pu être enregistrée ; la prochaine synchronisation réessaiera."
},
"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.",
@@ -5293,6 +5384,7 @@
"connected": "Connecté",
"errorReconnect": "Erreur — reconnecter",
"warningServerError": "Échec de synchronisation",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "En pause — reconnecter manuellement",
"notConnected": "Non connecté",
"justNow": "à l’instant",
@@ -7873,6 +7965,9 @@
"saveSelected": "Enregistrer {count} sélectionnées",
"discardAll": "Tout abandonner",
"savedToast": "{count} résultats de laboratoire enregistrés",
"savedPartialToast": "{count} résultats de laboratoire enregistrés, {skipped} ignorés car en double",
"savedNothingToast": "Rien enregistré : {skipped} valeurs étaient déjà présentes",
"savedEmptyToast": "Rien à enregistrer",
"noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.",
"nothingToSave": "Sélectionnez au moins une valeur à enregistrer.",
"saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.",
@@ -8461,6 +8556,61 @@
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie."
},
"records": {
"aiInclusion": {
"cardTitle": "Accès du profil par l'IA",
"cardDescription": "Choisissez les parties de votre profil de santé que le Coach et les synthèses complètes peuvent utiliser.",
"sectionAboutMe": "À propos de moi",
"sectionConditions": "Maladies chroniques",
"sectionAllergies": "Allergies et intolérances",
"sectionCoachFocus": "Priorité du Coach",
"sectionFamilyHistory": "Antécédents familiaux",
"sectionSmoking": "Tabagisme",
"sectionAlcohol": "Habitudes de consommation d'alcool",
"sectionShiftSchedule": "Organisation du travail",
"save": "Enregistrer l'accès",
"savedToast": "Accès du profil par l'IA enregistré",
"saveError": "Impossible d'enregistrer l'accès du profil par l'IA",
"loadError": "Impossible de charger l'accès du profil par l'IA"
},
"profileFacts": {
"cardTitle": "Contexte du mode de vie",
"cardDescription": "Indiquez les faits actuels qui modifient l'interprétation de vos mesures de santé.",
"smokingLabel": "Tabagisme",
"alcoholLabel": "Habitudes de consommation d'alcool",
"shiftLabel": "Organisation du travail",
"notRecorded": "Non renseigné",
"unreadable": "La valeur enregistrée n'a pas pu être lue",
"save": "Enregistrer",
"correct": "Corriger",
"savedToast": "Contexte du mode de vie enregistré",
"saveError": "Impossible d'enregistrer le contexte du mode de vie",
"loadError": "Impossible de charger le contexte du mode de vie",
"remove": "Supprimer : {kind}",
"removeConfirmTitle": "Supprimer : {kind} ?",
"removeConfirmDescription": "La valeur actuelle sera considérée comme non renseignée. Son historique daté sera conservé.",
"removedToast": "{kind} supprimé du contexte actuel du mode de vie",
"removeError": "Impossible de supprimer : {kind}",
"historyTitle": "Valeurs précédentes",
"to": "à",
"values": {
"SMOKING_STATUS": {
"NEVER": "N'a jamais fumé",
"FORMER": "A arrêté de fumer",
"CURRENT": "Fume actuellement"
},
"ALCOHOL_PATTERN": {
"NONE": "Aucune consommation",
"OCCASIONAL": "Occasionnelle",
"WEEKLY": "Hebdomadaire",
"MOST_DAYS": "La plupart des jours"
},
"SHIFT_SCHEDULE": {
"NONE": "Pas de travail posté",
"FIXED_SHIFT": "Poste fixe",
"ROTATING": "Postes tournants"
}
}
},
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
"allergies": {
"cardTitle": "Allergies & intolérances",
@@ -8819,6 +8969,8 @@
"description": "Description",
"descriptionPlaceholder": "Ce que cela mesure (facultatif)",
"targetHint": "La plage cible est facultative et s'affiche comme une bande sur le graphique.",
"correlationEnabled": "Utiliser dans l’analyse des corrélations",
"correlationEnabledHint": "Ajoute cette mesure comme canal comportemental quotidien plafonné. Désactivé par défaut.",
"requiredError": "Le nom et l'unité sont obligatoires",
"rangeOrderError": "La cible basse ne doit pas dépasser la cible haute",
"save": "Enregistrer",
@@ -8979,6 +9131,16 @@
"notDetected": "aucun rythme irrégulier",
"inconclusive": "un résultat non concluant"
}
},
"sameTimeBaseline": {
"title": {
"below": "En retrait de votre rythme habituel",
"above": "En avance sur votre rythme habituel"
},
"body": {
"below": "À {time} vous en êtes à {today} pas. Un jour habituel, vous tournez autour de {typical} à cette heure.",
"above": "À {time} vous en êtes à {today} pas, au-dessus des {typical} habituels à cette heure."
}
}
},
"action": {
@@ -8990,7 +9152,8 @@
"checkinLetGo": "Laisser tomber",
"viewMilestone": "Voir",
"viewPulse": "Voir le pouls",
"viewEcg": "Voir l’enregistrement"
"viewEcg": "Voir l’enregistrement",
"viewSteps": "Voir les pas"
},
"today": {
"scoreLabel": "Score de santé",
@@ -9040,6 +9203,7 @@
}
},
"reportSelection": {
"leafAnamnesis": "Profil de santé",
"groupIdentity": "Données personnelles",
"groupVitals": "Signes vitaux",
"groupCardio": "Cœur et circulation",
+212 -48
View File
@@ -124,6 +124,15 @@
},
"doctorReport": {
"title": "Rapporto sanitario",
"anamnesisTitle": "Profilo sanitario",
"anamnesisNotRecorded": "Non registrato",
"anamnesisUnreadable": "Impossibile leggere il valore salvato",
"anamnesisConditions": "Patologie croniche",
"anamnesisSmoking": "Abitudine al fumo",
"anamnesisAlcohol": "Consumo di alcol",
"anamnesisShiftSchedule": "Orario di lavoro",
"anamnesisColFact": "Voce del profilo",
"anamnesisColValue": "Valore registrato",
"subtitle": "HealthLog — Rapporto personale di salute",
"patient": "Paziente",
"dateOfBirth": "Data di nascita",
@@ -396,10 +405,6 @@
},
"welcomeBack": "{greeting}, bentornato.",
"welcomeBackWithName": "{greeting} {name}, bentornato.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dosi di oggi"
},
"greetingSalutation": "Ciao, {name}",
"streakLabel": "giorni di fila",
"weight": "Peso",
@@ -452,11 +457,22 @@
"compliance7d": "Aderenza (7 giorni)",
"mood": "Umore",
"customizeTitle": "Layout della dashboard",
"scoreRingsTitle": "Anelli dei punteggi",
"scoreRingsDescription": "Fino a tre anelli aggiuntivi accanto al punteggio di salute, ad esempio prontezza, recupero, sonno o aderenza. Trascina per riordinare; il punteggio di salute è primo per impostazione predefinita.",
"heroItemsTitle": "In evidenza oggi",
"heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.",
"heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.",
"heroItems": {
"coach_checkin": "Verifiche con Coach",
"dose_window": "Dosi dei farmaci",
"preventive_care": "Prevenzione",
"sync_issue": "Problemi di sincronizzazione",
"milestone": "Traguardi",
"ecg_new_recording": "Nuove registrazioni ECG",
"tension_window": "Periodi di tensione",
"same_time_baseline": "Attività alla stessa ora"
},
"layoutReset": "Ripristina i valori predefiniti",
"layoutResetTitle": "Reimpostare il layout della dashboard?",
"layoutResetBody": "L’ordine dei riquadri, quelli che hai nascosto e gli anelli scelti tornano ai valori predefiniti. La disposizione non è salvata altrove.",
"layoutResetBody": "Il layout dei riquadri e le scelte per la sezione In evidenza oggi tornano ai valori predefiniti.",
"layoutResetConfirm": "Reimposta layout",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
@@ -773,6 +789,8 @@
"hydrated": "bevuto acqua",
"walked": "camminato",
"alcohol": "alcol",
"caffeine": "caffeina",
"nicotine": "nicotina",
"family": "famiglia",
"friends": "amici",
"party": "festa",
@@ -2081,7 +2099,6 @@
},
"comparison": {
"toggleLabel": "Confronta con",
"toggleHint": "Confronta il periodo attuale con lo stesso periodo dell’anno precedente.",
"baseline": {
"none": "Disattivato",
"lastMonth": "Mese precedente",
@@ -2101,6 +2118,15 @@
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
},
"insights": {
"pattern": {
"dismiss": "Ignora",
"dismissed": "Questo schema resta ignorato finché i dati non cambiano.",
"restore": "Mostra di nuovo",
"dismissError": "Impossibile ignorare questo schema.",
"restoreError": "Impossibile ripristinare questo schema.",
"loadError": "Impossibile caricare gli schemi.",
"empty": "Nessuno schema attuale ha superato la soglia di evidenza."
},
"correlation": {
"direction": {
"higher": "salire",
@@ -2226,34 +2252,86 @@
"heroActionAskCoach": "Chiedi al coach",
"healthScore": {
"label": "Punteggio di salute",
"progressAria": "Avanzamento del punteggio di salute",
"deltaVsLastWeek": "{delta} rispetto alla settimana scorsa",
"deltaUnavailable": "Ancora nessuno storico — ripassa la prossima settimana.",
"restModePaused": "In pausa durante la malattia — oggi il tuo punteggio non viene valutato.",
"componentBp": "PA",
"componentWeight": "Peso",
"componentMood": "Umore",
"componentCompliance": "Aderenza",
"deltaExplainer": {
"title": "Come si calcola la variazione",
"description": "Variazione rispetto alla settimana scorsa: {delta}",
"body": "Il punteggio combina PA, peso, umore e aderenza. Confrontiamo il punteggio di oggi con quello dello stesso momento della settimana scorsa. Registra una nuova misurazione o una nota di umore per spingerlo verso l'alto."
"algorithmChanged": "Il punteggio usa ora intervalli di riferimento cardiometabolici pubblicati; il peso non è più incluso perché il vecchio obiettivo derivava dall'altezza anziché da un obiettivo personale, la variabilità quotidiana dell'umore e l'aderenza ai farmaci sono azioni anziché stati di salute, e la frequenza cardiaca a riposo e l'HRV non hanno un obiettivo universale. I progressi del peso restano visibili sotto come contesto personale, gli altri segnali conservano le proprie schede e un numero nuovo non significa che la tua salute sia cambiata.",
"dismissAlgorithmChanged": "Chiudi la nota sul metodo",
"band": {
"green": "Nell'intervallo di riferimento",
"yellow": "Alcuni pilastri richiedono attenzione",
"red": "Uno o più pilastri sono fuori dall'intervallo di riferimento"
},
"provenance": {
"toggle": "Composto da",
"mixedBanner": "Alcuni componenti combinano più fonti. Il dettaglio mostra il valore più recente per componente.",
"footnote": "I pesi sono ricalcolati in base ai dati effettivamente disponibili.",
"asOfLabel": "al {date}",
"provisional": "Provvisorio — solo {count} di {total} valori disponibili",
"provisionalBadge": "Provvisorio",
"sourceAria": "Fonte: {source}",
"sources": {
"manual": "Manuale",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Misto",
"none": "Nessun dato"
}
"method": "Media con peso uguale dei pilastri idonei. Il pilastro più basso determina la fascia complessiva.",
"insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.",
"composition": "Inclusi: {pillars}",
"bandSetter": "Fascia complessiva determinata da {pillar}",
"versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.",
"methodVersion": "Versione del metodo {version}.",
"none": "nessuno",
"delta": "{delta} rispetto al periodo precedente confrontabile",
"deltaReason": {
"algorithm_changed": "Nessun confronto perché è cambiato il metodo di calcolo.",
"composition_changed": "Nessun confronto perché sono cambiati i pilastri inclusi.",
"first_eligibility_window": "Nessun confronto perché questo è il primo periodo idoneo.",
"below_noise_floor": "La variazione rientra nella fluttuazione combinata delle misurazioni.",
"no_previous_window": "Non è disponibile un periodo precedente confrontabile.",
"no_current_score": "Nessun confronto perché il periodo attuale non è idoneo."
},
"restMode": "Modalità riposo attiva dal {since}. Il punteggio è mostrato come contesto e non viene penalizzato.",
"pillar": {
"bloodPressure": "Pressione arteriosa",
"glycaemia": "Glicemia",
"activity": "Attività",
"sleep": "Sonno",
"adiposity": "Adiposità",
"wellbeing": "Benessere",
"fitness": "Forma fisica misurata",
"lipids": "Lipidi"
},
"observed": "Osservato: {value}",
"reference": "Riferimento: {value}",
"fastingObserved": "Glicemia a digiuno: {value} {unit}",
"fastingReference": "Da {low} a {high} {unit} a digiuno",
"personalReference": "Il tuo obiettivo: {value}",
"source": "Fonte: {source}, al {date}",
"observedActivity": "{value} passi al giorno",
"observedSleep": "{value} minuti per notte",
"observedAdiposity": "Rapporto vita-altezza {value}",
"observedFitness": "{value} {unit}, misurato",
"referenceActivity": "Il beneficio si stabilizza a {high} passi al giorno",
"referenceSleep": "Almeno {low} minuti per notte; il sonno più lungo non viene penalizzato",
"referenceAdiposity": "Rapporto vita-altezza inferiore a {high}",
"referenceFitness": "Da {low} a {high} {unit}, intervallo di riferimento per età e sesso",
"referenceHba1c": "HbA1c da {low} a {high}%",
"referenceDetail": "{label} · {source} · {kind} · da {low} a {high}",
"openBound": "aperto",
"laboratoryReferenceSource": "Intervalli di riferimento del laboratorio",
"personalTargetSource": "Obiettivo personale",
"referenceKind": {
"clinical": "Soglia clinica",
"population": "Percentile della popolazione",
"guideline": "Intervallo delle linee guida"
},
"reason": {
"readFailed": "Impossibile caricare i dati",
"notTracked": "Non monitorato",
"missingProfile": "Manca l'età o il profilo di riferimento",
"missingHeight": "Manca l'altezza",
"incompletePanel": "È necessario un pannello recente completo con intervalli di riferimento",
"crisis": "Al posto del punteggio vengono mostrate indicazioni di sicurezza",
"insufficientHistory": "Dati recenti idonei insufficienti",
"unavailable": "Questo pilastro non è attualmente idoneo"
},
"pillarInsufficient": "Nessun punteggio per il pilastro: {reason}",
"tension": "I segnali di preparazione differiscono. Favorevoli: {positive}. Sotto sforzo: {negative}.",
"returnToBand": "{metric} è di nuovo nel tuo intervallo abituale da {days} giorni.",
"weightGoal": {
"title": "Obiettivo di peso personale",
"value": "Ora {current} {unit}. Obiettivo da {min} a {max} {unit}. Distanza dall'obiettivo: {distance} {unit}.",
"delta": "La distanza dall'obiettivo è cambiata di {delta} {unit} dalla misurazione precedente.",
"noDelta": "Non è disponibile una misurazione del peso precedente per il confronto.",
"no_personal_goal": "Imposta un obiettivo di peso personale per vedere qui i progressi.",
"weight_not_tracked": "Registra il peso per vedere i progressi verso il tuo obiettivo.",
"read_failed": "Non è stato possibile caricare i dati del peso. Riprova.",
"notScored": "I progressi verso l'obiettivo di peso sono un contesto personale e non influenzano il punteggio di salute."
}
},
"suggestedPrompts": {
@@ -2856,6 +2934,14 @@
"scoresBack": "Torna a Insights",
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo. HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
},
"units": {
"steps": "passi",
"flights": "piani",
"falls": "cadute",
"years": "anni",
"breathingEvents": "eventi",
"respiratoryRate": "respiri/min"
},
"sleep": {
"title": "Sonno",
"description": "Suddivisione per fasi e durata notturna, da Apple Health.",
@@ -3640,6 +3726,10 @@
"TRAJECTORY": {
"method": "Una retta ai minimi quadrati adattata ai tuoi valori giornalieri recenti e proiettata in avanti su un breve orizzonte, con l’intervallo di previsione classico che si allarga man mano che si estende. Calcolata solo quando la tendenza recente è abbastanza marcata e la tua cronologia è abbastanza lunga e aggiornata; altrimenti non viene tracciata alcuna linea.",
"caveat": "Una proiezione della tua tendenza recente — non una previsione, un obiettivo o una prognosi clinica. Vale solo se lo schema recente continua; la banda ombreggiata mostra quanto rapidamente quella certezza svanisce."
},
"SAME_TIME_BASELINE": {
"method": "Il tuo totale progressivo di oggi confrontato con la mediana della stessa ora locale nelle ultime quattro settimane, con una banda robusta mediana ± MAD attorno. Contano solo i giorni con abbastanza dettaglio registrato, e nessun confronto viene mostrato finché non ce ne sono almeno quattordici.",
"caveat": "Un confronto con la tua storia recente, non un obiettivo. Dice dove si colloca oggi rispetto alla tua abitudine a quest'ora, e nulla sul fatto che quell'abitudine sia quella giusta per te."
}
},
"scores": {
@@ -3673,6 +3763,13 @@
"caveat": "Una proiezione della tua tendenza recente, non una previsione. La banda ombreggiata si allarga per mostrare quanto rapidamente cresce l’incertezza.",
"insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato.",
"daysNeeded": "Servono ancora circa {count} giorni di registrazione prima che possa comparire una proiezione."
},
"sameTime": {
"sectionTitle": "Oggi finora",
"sectionSubtitle": "I tuoi totali progressivi rispetto a ciò che per te è abituale a quest'ora",
"learning": "Sto imparando la tua giornata tipo — {count} di {target} giorni",
"framing": "Abituale entro le {time}: {typical} {unit}",
"comparisonLabel": "La tua giornata tipo"
}
},
"cardioFitness": {
@@ -3858,17 +3955,6 @@
"forwardHolding": "Per ora è tutto stabile."
}
},
"tension": {
"label": "Lettura sincera: {positive} ha un bell'aspetto, ma {negative} è in calo — il complessivo si attesta su {band}.",
"contributor": {
"green": "verde",
"yellow": "giallo",
"red": "rosso"
}
},
"streak": {
"returnLabel": "Il tuo {metric} è di nuovo nel tuo intervallo abituale dopo {count} giorni — qualunque cosa fosse, è passata."
},
"updatedTodayAt": "Aggiornato oggi, {time}",
"updatedYesterday": "Aggiornato ieri",
"updatedOn": "Aggiornato il {date}",
@@ -4499,7 +4585,7 @@
},
"fullBackup": {
"title": "Backup completo",
"description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti e gli allenamenti in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
"description": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON. Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
"scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
"encryptToggle": "Cifra con una passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
@@ -5148,6 +5234,11 @@
"withingsFullSyncTitle": "Sincronizzazione completa?",
"withingsFullSyncDescription": "Tutti i dati Withings disponibili verranno sincronizzati completamente. Potrebbe richiedere del tempo a seconda del tuo storico.",
"withingsSyncResult": "{count} misurazioni sincronizzate",
"syncOutcome": {
"empty": "Non c'è nulla di nuovo da importare. Tutto è aggiornato.",
"failed": "Non è stato salvato nulla. La prossima esecuzione riprova.",
"partial": "{count} misurazioni sincronizzate. Una parte dei dati non è stata salvata; la prossima esecuzione riprova."
},
"withingsFullSyncResult": "{count} misurazioni sincronizzate completamente",
"withingsSyncFailed": "Sincronizzazione non riuscita",
"withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.",
@@ -5293,6 +5384,7 @@
"connected": "Connesso",
"errorReconnect": "Errore — riconnetti",
"warningServerError": "Sincronizzazione in errore",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "In pausa — riconnettere manualmente",
"notConnected": "Non connesso",
"justNow": "proprio ora",
@@ -7873,6 +7965,9 @@
"saveSelected": "Salva {count} selezionati",
"discardAll": "Scarta tutto",
"savedToast": "{count} risultati di laboratorio salvati",
"savedPartialToast": "{count} risultati di laboratorio salvati, {skipped} ignorati perché duplicati",
"savedNothingToast": "Nulla salvato: {skipped} valori erano già registrati",
"savedEmptyToast": "Nulla da salvare",
"noRows": "Nessun valore trovato in questo referto.",
"nothingToSave": "Seleziona almeno un valore da salvare.",
"saveFailed": "Impossibile salvare i valori. Riprova.",
@@ -8461,6 +8556,61 @@
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova."
},
"records": {
"aiInclusion": {
"cardTitle": "Accesso dell'IA al profilo",
"cardDescription": "Scegli quali parti del tuo profilo sanitario possono usare il Coach e i riepiloghi completi.",
"sectionAboutMe": "Informazioni personali",
"sectionConditions": "Patologie croniche",
"sectionAllergies": "Allergie e intolleranze",
"sectionCoachFocus": "Priorità del Coach",
"sectionFamilyHistory": "Anamnesi familiare",
"sectionSmoking": "Abitudine al fumo",
"sectionAlcohol": "Consumo di alcol",
"sectionShiftSchedule": "Orario di lavoro",
"save": "Salva accesso",
"savedToast": "Accesso dell'IA al profilo salvato",
"saveError": "Impossibile salvare l'accesso dell'IA al profilo",
"loadError": "Impossibile caricare l'accesso dell'IA al profilo"
},
"profileFacts": {
"cardTitle": "Contesto dello stile di vita",
"cardDescription": "Registra i dati attuali che cambiano l'interpretazione delle tue misurazioni sanitarie.",
"smokingLabel": "Abitudine al fumo",
"alcoholLabel": "Consumo di alcol",
"shiftLabel": "Orario di lavoro",
"notRecorded": "Non registrato",
"unreadable": "Impossibile leggere il valore salvato",
"save": "Salva",
"correct": "Correggi",
"savedToast": "Contesto dello stile di vita salvato",
"saveError": "Impossibile salvare il contesto dello stile di vita",
"loadError": "Impossibile caricare il contesto dello stile di vita",
"remove": "Rimuovi {kind}",
"removeConfirmTitle": "Rimuovere {kind}?",
"removeConfirmDescription": "Il valore attuale risulterà non registrato. La cronologia datata verrà conservata.",
"removedToast": "{kind} rimosso dal contesto attuale dello stile di vita",
"removeError": "Impossibile rimuovere {kind}",
"historyTitle": "Valori precedenti",
"to": "a",
"values": {
"SMOKING_STATUS": {
"NEVER": "Non ha mai fumato",
"FORMER": "Ha smesso di fumare",
"CURRENT": "Fuma attualmente"
},
"ALCOHOL_PATTERN": {
"NONE": "Nessun consumo",
"OCCASIONAL": "Occasionale",
"WEEKLY": "Settimanale",
"MOST_DAYS": "Quasi ogni giorno"
},
"SHIFT_SCHEDULE": {
"NONE": "Nessun lavoro a turni",
"FIXED_SHIFT": "Turno fisso",
"ROTATING": "Turni a rotazione"
}
}
},
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
"allergies": {
"cardTitle": "Allergie e intolleranze",
@@ -8819,6 +8969,8 @@
"description": "Descrizione",
"descriptionPlaceholder": "Cosa misura (facoltativo)",
"targetHint": "L'intervallo obiettivo è facoltativo e viene disegnato come una banda sul grafico.",
"correlationEnabled": "Usa nell’analisi delle correlazioni",
"correlationEnabledHint": "Aggiunge questa metrica come canale comportamentale giornaliero con un limite. È disattivata per impostazione predefinita.",
"requiredError": "Nome e unità sono obbligatori",
"rangeOrderError": "L'obiettivo minimo non può superare quello massimo",
"save": "Salva",
@@ -8979,6 +9131,16 @@
"notDetected": "nessun ritmo irregolare",
"inconclusive": "un risultato non conclusivo"
}
},
"sameTimeBaseline": {
"title": {
"below": "Sotto il tuo ritmo abituale",
"above": "Sopra il tuo ritmo abituale"
},
"body": {
"below": "Alle {time} sei a {today} passi. In una giornata tipo a quest'ora sei intorno a {typical}.",
"above": "Alle {time} sei a {today} passi, sopra i {typical} che per te sono abituali a quest'ora."
}
}
},
"action": {
@@ -8990,7 +9152,8 @@
"checkinLetGo": "Lascia andare",
"viewMilestone": "Vedi",
"viewPulse": "Vedi battito",
"viewEcg": "Vedi registrazione"
"viewEcg": "Vedi registrazione",
"viewSteps": "Vedi i passi"
},
"today": {
"scoreLabel": "Punteggio di salute",
@@ -9040,6 +9203,7 @@
}
},
"reportSelection": {
"leafAnamnesis": "Profilo sanitario",
"groupIdentity": "Dati anagrafici",
"groupVitals": "Parametri vitali",
"groupCardio": "Cuore e circolazione",
+212 -48
View File
@@ -124,6 +124,15 @@
},
"doctorReport": {
"title": "Raport zdrowotny",
"anamnesisTitle": "Profil zdrowotny",
"anamnesisNotRecorded": "Brak danych",
"anamnesisUnreadable": "Nie udało się odczytać zapisanej wartości",
"anamnesisConditions": "Choroby przewlekłe",
"anamnesisSmoking": "Palenie tytoniu",
"anamnesisAlcohol": "Wzorzec spożycia alkoholu",
"anamnesisShiftSchedule": "Harmonogram pracy",
"anamnesisColFact": "Pozycja profilu",
"anamnesisColValue": "Zapisana wartość",
"subtitle": "HealthLog — Osobisty raport zdrowotny",
"patient": "Pacjent",
"dateOfBirth": "Data urodzenia",
@@ -396,10 +405,6 @@
},
"welcomeBack": "{greeting}, witaj ponownie.",
"welcomeBackWithName": "{greeting} {name}, witaj ponownie.",
"hero": {
"scoreLabel": "Health Score",
"ringDoses": "Dzisiejsze dawki"
},
"greetingSalutation": "Cześć, {name}",
"streakLabel": "dni z rzędu",
"weight": "Waga",
@@ -452,11 +457,22 @@
"compliance7d": "Przestrzeganie (7 dni)",
"mood": "Nastrój",
"customizeTitle": "Układ panelu",
"scoreRingsTitle": "Pierścienie wyników",
"scoreRingsDescription": "Maksymalnie trzy dodatkowe pierścienie obok wskaźnika zdrowia — np. gotowość, regeneracja, sen lub przestrzeganie zaleceń. Przeciągnij, aby zmienić kolejność; wskaźnik zdrowia jest domyślnie na początku.",
"heroItemsTitle": "Dzisiejsze wyróżnienia",
"heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.",
"heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.",
"heroItems": {
"coach_checkin": "Podsumowania z Coachem",
"dose_window": "Dawki leków",
"preventive_care": "Profilaktyka",
"sync_issue": "Problemy z synchronizacją",
"milestone": "Kamienie milowe",
"ecg_new_recording": "Nowe zapisy EKG",
"tension_window": "Okresy napięcia",
"same_time_baseline": "Aktywność o tej samej porze"
},
"layoutReset": "Przywróć wartości domyślne",
"layoutResetTitle": "Zresetować układ pulpitu?",
"layoutResetBody": "Kolejność kafelków, ukryte kafelki i wybrane pierścienie wracają do ustawień domyślnych. Ten układ nie jest nigdzie indziej zapisany.",
"layoutResetBody": "Układ kafelków i wybory dotyczące dzisiejszych wyróżnień wracają do ustawień domyślnych.",
"layoutResetConfirm": "Zresetuj układ",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
@@ -773,6 +789,8 @@
"hydrated": "piłem wodę",
"walked": "spacer",
"alcohol": "alkohol",
"caffeine": "kofeina",
"nicotine": "nikotyna",
"family": "rodzina",
"friends": "znajomi",
"party": "impreza",
@@ -2081,7 +2099,6 @@
},
"comparison": {
"toggleLabel": "Porównaj z",
"toggleHint": "Porównuje bieżący okres z tym samym okresem rok wcześniej.",
"baseline": {
"none": "Wyłączone",
"lastMonth": "Poprzedni miesiąc",
@@ -2101,6 +2118,15 @@
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
},
"insights": {
"pattern": {
"dismiss": "Odrzuć",
"dismissed": "Ten wzorzec pozostanie odrzucony, dopóki nie zmienią się dowody.",
"restore": "Pokaż ponownie",
"dismissError": "Nie udało się odrzucić tego wzorca.",
"restoreError": "Nie udało się przywrócić tego wzorca.",
"loadError": "Nie udało się wczytać wzorców.",
"empty": "Żaden aktualny wzorzec nie przekroczył progu dowodów."
},
"correlation": {
"direction": {
"higher": "wzrostu",
@@ -2226,34 +2252,86 @@
"heroActionAskCoach": "Zapytaj coacha",
"healthScore": {
"label": "Wynik zdrowia",
"progressAria": "Postęp wyniku zdrowia",
"deltaVsLastWeek": "{delta} w porównaniu z ubiegłym tygodniem",
"deltaUnavailable": "Brak historii — zajrzyj w przyszłym tygodniu.",
"restModePaused": "Wstrzymane podczas choroby — Twój wynik nie jest dziś oceniany.",
"componentBp": "RR",
"componentWeight": "Waga",
"componentMood": "Nastrój",
"componentCompliance": "Przestrzeganie",
"deltaExplainer": {
"title": "Jak liczona jest ta zmiana",
"description": "Zmiana względem ubiegłego tygodnia: {delta}",
"body": "Wynik łączy ciśnienie, wagę, nastrój i przyjmowanie leków. Porównujemy dzisiejszy wynik z tym samym momentem tydzień wcześniej. Dodaj świeży pomiar lub wpis o nastroju, żeby pchnąć go w górę."
"algorithmChanged": "Wynik korzysta teraz z opublikowanych kardiometabolicznych zakresów referencyjnych; masa ciała nie jest już uwzględniana, ponieważ poprzedni cel wynikał ze wzrostu, a nie z celu ustawionego przez użytkownika, codzienna zmienność nastroju i przestrzeganie zaleceń lekowych są działaniami, a nie stanami zdrowia, zaś tętno spoczynkowe i HRV nie mają uniwersalnego celu. Postęp masy ciała pozostaje poniżej jako osobisty kontekst, pozostałe sygnały zachowują własne karty, a nowa liczba nie oznacza zmiany stanu zdrowia.",
"dismissAlgorithmChanged": "Zamknij informację o metodzie",
"band": {
"green": "W zakresie referencyjnym",
"yellow": "Niektóre filary wymagają uwagi",
"red": "Co najmniej jeden filar jest poza zakresem referencyjnym"
},
"provenance": {
"toggle": "Składa się z",
"mixedBanner": "Niektóre komponenty łączą wiele źródeł. Zestawienie pokazuje najświeższą wartość dla każdego komponentu.",
"footnote": "Wagi są przeskalowywane do dostępnych danych.",
"asOfLabel": "stan na {date}",
"provisional": "Wstępny — dostępne {count} z {total} danych",
"provisionalBadge": "Wstępne",
"sourceAria": "Źródło: {source}",
"sources": {
"manual": "Ręczne",
"withings": "Withings",
"appleHealth": "Apple Health",
"mixed": "Mieszane",
"none": "Brak danych"
}
"method": "Średnia z równą wagą kwalifikujących się filarów. Najniższy filar wyznacza ogólny zakres.",
"insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.",
"composition": "Uwzględniono: {pillars}",
"bandSetter": "Ogólny zakres wyznacza {pillar}",
"versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.",
"methodVersion": "Wersja metody {version}.",
"none": "brak",
"delta": "{delta} względem poprzedniego porównywalnego okresu",
"deltaReason": {
"algorithm_changed": "Brak porównania, ponieważ zmieniła się metoda obliczeń.",
"composition_changed": "Brak porównania, ponieważ zmieniły się uwzględnione filary.",
"first_eligibility_window": "Brak porównania, ponieważ to pierwszy kwalifikujący się okres.",
"below_noise_floor": "Zmiana mieści się w łącznej zmienności pomiarów.",
"no_previous_window": "Brak wcześniejszego porównywalnego okresu.",
"no_current_score": "Brak porównania, ponieważ bieżący okres nie spełnia warunków."
},
"restMode": "Tryb odpoczynku aktywny od {since}. Wynik służy jako kontekst i nie jest obniżany.",
"pillar": {
"bloodPressure": "Ciśnienie krwi",
"glycaemia": "Glikemia",
"activity": "Aktywność",
"sleep": "Sen",
"adiposity": "Otłuszczenie",
"wellbeing": "Samopoczucie",
"fitness": "Zmierzona wydolność",
"lipids": "Lipidy"
},
"observed": "Zaobserwowano: {value}",
"reference": "Zakres referencyjny: {value}",
"fastingObserved": "Glukoza na czczo: {value} {unit}",
"fastingReference": "Od {low} do {high} {unit} na czczo",
"personalReference": "Twój cel: {value}",
"source": "Źródło: {source}, stan na {date}",
"observedActivity": "{value} kroków dziennie",
"observedSleep": "{value} minut na noc",
"observedAdiposity": "Stosunek obwodu talii do wzrostu {value}",
"observedFitness": "{value} {unit}, zmierzone",
"referenceActivity": "Korzyść stabilizuje się przy {high} krokach dziennie",
"referenceSleep": "Co najmniej {low} minut na noc; dłuższy sen nie obniża wyniku",
"referenceAdiposity": "Stosunek obwodu talii do wzrostu poniżej {high}",
"referenceFitness": "Od {low} do {high} {unit}, zakres odniesienia według wieku i płci",
"referenceHba1c": "HbA1c od {low} do {high}%",
"referenceDetail": "{label} · {source} · {kind} · od {low} do {high}",
"openBound": "otwarty",
"laboratoryReferenceSource": "Zakresy referencyjne laboratorium",
"personalTargetSource": "Osobisty cel",
"referenceKind": {
"clinical": "Próg kliniczny",
"population": "Percentyl populacji",
"guideline": "Zakres wytycznych"
},
"reason": {
"readFailed": "Nie udało się wczytać danych",
"notTracked": "Brak pomiarów",
"missingProfile": "Brakuje wieku lub profilu referencyjnego",
"missingHeight": "Brakuje wzrostu",
"incompletePanel": "Potrzebny jest aktualny pełny panel z zakresami referencyjnymi",
"crisis": "Zamiast wyniku wyświetlane są wskazówki bezpieczeństwa",
"insufficientHistory": "Za mało aktualnych danych spełniających kryteria",
"unavailable": "Ten filar obecnie nie spełnia kryteriów"
},
"pillarInsufficient": "Brak wyniku filaru: {reason}",
"tension": "Sygnały gotowości są rozbieżne. Sprzyjające: {positive}. Obciążone: {negative}.",
"returnToBand": "{metric} od {days} dni ponownie mieści się w typowym zakresie.",
"weightGoal": {
"title": "Osobisty cel wagowy",
"value": "Obecnie {current} {unit}. Cel od {min} do {max} {unit}. Odległość od celu: {distance} {unit}.",
"delta": "Odległość od celu zmieniła się o {delta} {unit} od poprzedniego pomiaru.",
"noDelta": "Brak wcześniejszego pomiaru masy ciała do porównania.",
"no_personal_goal": "Ustaw osobisty cel wagowy, aby zobaczyć tutaj postęp.",
"weight_not_tracked": "Zapisz pomiar masy ciała, aby zobaczyć postęp w kierunku celu.",
"read_failed": "Nie udało się wczytać danych masy ciała. Spróbuj ponownie.",
"notScored": "Postęp celu wagowego jest osobistym kontekstem i nie wpływa na wynik zdrowia."
}
},
"suggestedPrompts": {
@@ -2856,6 +2934,14 @@
"scoresBack": "Powrót do Insights",
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia. HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
},
"units": {
"steps": "kroki",
"flights": "piętra",
"falls": "upadki",
"years": "lata",
"breathingEvents": "zdarzenia",
"respiratoryRate": "oddechów/min"
},
"sleep": {
"title": "Sen",
"description": "Podział na fazy i nocna długość snu, dane z Apple Health.",
@@ -3640,6 +3726,10 @@
"TRAJECTORY": {
"method": "Prosta dopasowana metodą najmniejszych kwadratów do Twoich ostatnich wartości dziennych i rzutowana w przód na krótki horyzont, z klasycznym przedziałem predykcji, który poszerza się tym bardziej, im dalej sięga. Obliczana tylko wtedy, gdy ostatni trend jest dość wyraźny, a Twoja historia wystarczająco długa i aktualna; w przeciwnym razie linia nie jest rysowana.",
"caveat": "Rzut Twojego własnego ostatniego trendu — nie prognoza, cel ani kliniczne przewidywanie. Sprawdza się tylko, jeśli ostatni wzorzec się utrzyma; zacieniony pas pokazuje, jak szybko ta pewność maleje."
},
"SAME_TIME_BASELINE": {
"method": "Twoja dzisiejsza suma narastająca zestawiona z medianą tej samej godziny lokalnej z ostatnich czterech tygodni, z odpornym pasmem mediana ± MAD wokół niej. Liczą się tylko dni z wystarczającą liczbą zapisów, a porównanie nie pojawia się, dopóki nie ma ich co najmniej czternastu.",
"caveat": "Porównanie z twoją własną niedawną historią, a nie cel. Mówi, gdzie dziś jesteś względem swojego zwykłego wzorca o tej godzinie — i nic o tym, czy ten wzorzec jest dla ciebie właściwy."
}
},
"scores": {
@@ -3673,6 +3763,13 @@
"caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza. Zacieniony pas poszerza się, pokazując, jak szybko rośnie niepewność.",
"insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny.",
"daysNeeded": "Potrzeba jeszcze około {count} dni rejestrowania, zanim pojawi się rzut."
},
"sameTime": {
"sectionTitle": "Dziś do tej pory",
"sectionSubtitle": "Twoje sumy narastające względem tego, co o tej godzinie jest u ciebie zwykłe",
"learning": "Uczę się twojego zwykłego dnia — {count} z {target} dni",
"framing": "Zwykle do {time}: {typical} {unit}",
"comparisonLabel": "Twój zwykły dzień"
}
},
"cardioFitness": {
@@ -3858,17 +3955,6 @@
"forwardHolding": "Na razie wszystko jest stabilne."
}
},
"tension": {
"label": "Szczera ocena: {positive} wygląda dobrze, ale {negative} odstaje — wynik łączny ląduje na {band}.",
"contributor": {
"green": "zielony",
"yellow": "żółty",
"red": "czerwony"
}
},
"streak": {
"returnLabel": "Twój {metric} wrócił do twojego zwykłego zakresu po {count} dniach — cokolwiek to było, minęło."
},
"updatedTodayAt": "Zaktualizowano dzisiaj, {time}",
"updatedYesterday": "Zaktualizowano wczoraj",
"updatedOn": "Zaktualizowano {date}",
@@ -4499,7 +4585,7 @@
},
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze i treningi w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
"description": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON. Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
"scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
"encryptToggle": "Zaszyfruj hasłem-frazą",
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
@@ -5148,6 +5234,11 @@
"withingsFullSyncTitle": "Pełna synchronizacja?",
"withingsFullSyncDescription": "Wszystkie dostępne dane Withings zostaną w pełni zsynchronizowane. Może to chwilę potrwać w zależności od historii.",
"withingsSyncResult": "{count} pomiarów zsynchronizowano",
"syncOutcome": {
"empty": "Nie ma nic nowego do zaimportowania. Wszystko jest aktualne.",
"failed": "Nic nie zostało zapisane. Następne uruchomienie spróbuje ponownie.",
"partial": "Zsynchronizowano {count} pomiarów. Części danych nie udało się zapisać; następne uruchomienie spróbuje ponownie."
},
"withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano",
"withingsSyncFailed": "Synchronizacja nie powiodła się",
"withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.",
@@ -5293,6 +5384,7 @@
"connected": "Połączono",
"errorReconnect": "Błąd — połącz ponownie",
"warningServerError": "Synchronizacja nie działa",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "Wstrzymane — połącz ręcznie",
"notConnected": "Nie połączono",
"justNow": "przed chwilą",
@@ -7873,6 +7965,9 @@
"saveSelected": "Zapisz {count} wybranych",
"discardAll": "Odrzuć wszystko",
"savedToast": "Zapisano {count} wyników laboratoryjnych",
"savedPartialToast": "Zapisano {count} wyników laboratoryjnych, {skipped} pominięto jako duplikaty",
"savedNothingToast": "Nic nie zapisano: {skipped} wyników było już zarejestrowanych",
"savedEmptyToast": "Nie ma nic do zapisania",
"noRows": "W tym wyniku nie znaleziono żadnych wartości.",
"nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.",
"saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
@@ -8461,6 +8556,61 @@
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie."
},
"records": {
"aiInclusion": {
"cardTitle": "Dostęp AI do profilu",
"cardDescription": "Wybierz części profilu zdrowotnego, z których mogą korzystać Coach i pełne podsumowania.",
"sectionAboutMe": "O mnie",
"sectionConditions": "Choroby przewlekłe",
"sectionAllergies": "Alergie i nietolerancje",
"sectionCoachFocus": "Priorytet Coacha",
"sectionFamilyHistory": "Historia rodzinna",
"sectionSmoking": "Palenie tytoniu",
"sectionAlcohol": "Wzorzec spożycia alkoholu",
"sectionShiftSchedule": "Harmonogram pracy",
"save": "Zapisz dostęp",
"savedToast": "Zapisano dostęp AI do profilu",
"saveError": "Nie udało się zapisać dostępu AI do profilu",
"loadError": "Nie udało się wczytać dostępu AI do profilu"
},
"profileFacts": {
"cardTitle": "Kontekst stylu życia",
"cardDescription": "Zapisz aktualne informacje, które zmieniają interpretację pomiarów zdrowotnych.",
"smokingLabel": "Palenie tytoniu",
"alcoholLabel": "Wzorzec spożycia alkoholu",
"shiftLabel": "Harmonogram pracy",
"notRecorded": "Brak danych",
"unreadable": "Nie udało się odczytać zapisanej wartości",
"save": "Zapisz",
"correct": "Popraw",
"savedToast": "Zapisano kontekst stylu życia",
"saveError": "Nie udało się zapisać kontekstu stylu życia",
"loadError": "Nie udało się wczytać kontekstu stylu życia",
"remove": "Usuń: {kind}",
"removeConfirmTitle": "Usunąć: {kind}?",
"removeConfirmDescription": "Bieżąca wartość zmieni się na brak danych. Historia z datami zostanie zachowana.",
"removedToast": "Usunięto {kind} z bieżącego kontekstu stylu życia",
"removeError": "Nie udało się usunąć: {kind}",
"historyTitle": "Poprzednie wartości",
"to": "do",
"values": {
"SMOKING_STATUS": {
"NEVER": "Nigdy nie pali",
"FORMER": "Były palacz",
"CURRENT": "Obecnie pali"
},
"ALCOHOL_PATTERN": {
"NONE": "Nie spożywa",
"OCCASIONAL": "Okazjonalnie",
"WEEKLY": "Co tydzień",
"MOST_DAYS": "Przez większość dni"
},
"SHIFT_SCHEDULE": {
"NONE": "Bez pracy zmianowej",
"FIXED_SHIFT": "Stała zmiana",
"ROTATING": "Zmiany rotacyjne"
}
}
},
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
"allergies": {
"cardTitle": "Alergie i nietolerancje",
@@ -8819,6 +8969,8 @@
"description": "Opis",
"descriptionPlaceholder": "Co to mierzy (opcjonalnie)",
"targetHint": "Zakres docelowy jest opcjonalny i jest rysowany jako pasmo na wykresie.",
"correlationEnabled": "Używaj w analizie korelacji",
"correlationEnabledHint": "Dodaje tę metrykę jako ograniczony dzienny kanał zachowania. Domyślnie jest wyłączona.",
"requiredError": "Nazwa i jednostka są wymagane",
"rangeOrderError": "Cel dolny nie może przekraczać celu górnego",
"save": "Zapisz",
@@ -8979,6 +9131,16 @@
"notDetected": "brak nieregularnego rytmu",
"inconclusive": "wynik niejednoznaczny"
}
},
"sameTimeBaseline": {
"title": {
"below": "Poniżej swojego zwykłego tempa",
"above": "Powyżej swojego zwykłego tempa"
},
"body": {
"below": "Do {time} masz {today} kroków. W zwykły dzień o tej porze jesteś w okolicach {typical}.",
"above": "Do {time} masz {today} kroków, powyżej zwykłych {typical} o tej porze."
}
}
},
"action": {
@@ -8990,7 +9152,8 @@
"checkinLetGo": "Odpuść",
"viewMilestone": "Zobacz",
"viewPulse": "Zobacz tętno",
"viewEcg": "Zobacz zapis"
"viewEcg": "Zobacz zapis",
"viewSteps": "Zobacz kroki"
},
"today": {
"scoreLabel": "Wynik zdrowia",
@@ -9040,6 +9203,7 @@
}
},
"reportSelection": {
"leafAnamnesis": "Profil zdrowotny",
"groupIdentity": "Dane osobowe",
"groupVitals": "Parametry życiowe",
"groupCardio": "Serce i krążenie",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.33.2",
"version": "1.34.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",
@@ -0,0 +1,56 @@
-- v1.34.0 — keep the intraday shape of a cumulative day before the fold
-- destroys it.
--
-- The nightly cumulative drain collapses per-sample Apple Health rows into one
-- daily total after the 36-hour grace window and hard-deletes the originals in
-- the same transaction. That left no substrate at all for a "typical by 21:00"
-- comparison on any day older than the grace window — today's curve was
-- derivable, every older day's was already gone. This table holds the
-- twenty-four running totals the drain now folds out of those rows first.
--
-- Sidecar only: the canonical `stats:` daily measurement stays the reading of
-- record. Nothing stored here is a measurement.
--
-- Additive — one table, three indexes, one foreign key. No ALTER on an
-- existing table, no DROP.
CREATE TABLE IF NOT EXISTS "intraday_cumulative_profiles" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" "measurement_type" NOT NULL,
-- Local calendar day in the user's timezone. VARCHAR so the trailing
-- window read and the retention prune are both lexicographic range scans.
"date_key" VARCHAR(10) NOT NULL,
-- Twenty-four running totals; element h is the value accumulated through
-- the END of local hour h, so the array is non-decreasing.
"hourly_cumulative" DOUBLE PRECISION[],
"day_total" DOUBLE PRECISION NOT NULL,
-- Raw per-sample rows that backed the fold. A two-row day is not a shape.
"sample_count" INTEGER NOT NULL,
-- The zone the day was cut on. A later move leaves profiles on the old
-- clock, and the reader drops them rather than compare mismatched hours.
"timezone" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "intraday_cumulative_profiles_pkey" PRIMARY KEY ("id")
);
-- The natural identity: one profile per user x type x local day. Makes the
-- drain's upsert idempotent under a re-run or an overlapping nightly tick.
CREATE UNIQUE INDEX IF NOT EXISTS "intraday_cumulative_profiles_user_type_date_key"
ON "intraday_cumulative_profiles" ("user_id", "type", "date_key");
-- The trailing-window read the baseline issues: newest days first.
CREATE INDEX IF NOT EXISTS "intraday_cumulative_profiles_user_type_date_desc_idx"
ON "intraday_cumulative_profiles" ("user_id", "type", "date_key" DESC);
-- The retention prune sweeps every account at once, so it needs an index that
-- does not lead with the user.
CREATE INDEX IF NOT EXISTS "intraday_cumulative_profiles_date_key_idx"
ON "intraday_cumulative_profiles" ("date_key");
ALTER TABLE "intraday_cumulative_profiles"
ADD CONSTRAINT "intraday_cumulative_profiles_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,89 @@
-- v1.34.0 — section-level prompt inclusion and encrypted, effective-dated
-- health-profile facts.
--
-- The inclusion list is closed and starts with every section that existed when
-- this migration landed. Future enum members do not enter the static default,
-- so adding a section cannot silently widen prompt context.
--
-- Fact answers are encrypted BYTEA. The only plaintext classification is the
-- closed fact kind and its provenance. Corrections close the previous row and
-- mint a successor, preserving half-open [valid_from, valid_until) history.
CREATE TYPE "health_profile_ai_section" AS ENUM (
'ABOUT_ME',
'CONDITIONS',
'ALLERGIES',
'COACH_FOCUS',
'FAMILY_HISTORY',
'SMOKING_STATUS',
'ALCOHOL_PATTERN',
'SHIFT_SCHEDULE'
);
CREATE TYPE "health_profile_fact_kind" AS ENUM (
'SMOKING_STATUS',
'ALCOHOL_PATTERN',
'SHIFT_SCHEDULE'
);
CREATE TYPE "health_profile_fact_provenance" AS ENUM (
'USER_REPORTED',
'USER_CORRECTION'
);
ALTER TABLE "user_health_profiles"
ADD COLUMN "ai_included_sections" "health_profile_ai_section"[] NOT NULL
DEFAULT ARRAY[
'ABOUT_ME'::"health_profile_ai_section",
'CONDITIONS'::"health_profile_ai_section",
'ALLERGIES'::"health_profile_ai_section",
'COACH_FOCUS'::"health_profile_ai_section",
'FAMILY_HISTORY'::"health_profile_ai_section",
'SMOKING_STATUS'::"health_profile_ai_section",
'ALCOHOL_PATTERN'::"health_profile_ai_section",
'SHIFT_SCHEDULE'::"health_profile_ai_section"
];
CREATE TABLE "health_profile_fact_revisions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"kind" "health_profile_fact_kind" NOT NULL,
"value_encrypted" BYTEA NOT NULL,
"valid_from" TIMESTAMPTZ(6) NOT NULL,
"valid_until" TIMESTAMPTZ(6),
"provenance" "health_profile_fact_provenance" NOT NULL DEFAULT 'USER_REPORTED',
"superseded_by_revision_id" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "health_profile_fact_revisions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "health_profile_fact_revisions_valid_interval_check"
CHECK ("valid_until" IS NULL OR "valid_until" > "valid_from"),
CONSTRAINT "health_profile_fact_revisions_not_self_superseded_check"
CHECK ("superseded_by_revision_id" IS NULL OR "superseded_by_revision_id" <> "id")
);
CREATE UNIQUE INDEX "health_profile_fact_revisions_user_kind_valid_from_key"
ON "health_profile_fact_revisions" ("user_id", "kind", "valid_from");
-- Exactly one current, non-superseded row may exist for a user and fact kind.
-- Prisma cannot express this partial unique index, so the migration owns it.
CREATE UNIQUE INDEX "health_profile_fact_revisions_one_current"
ON "health_profile_fact_revisions" ("user_id", "kind")
WHERE "valid_until" IS NULL AND "superseded_by_revision_id" IS NULL;
CREATE INDEX "health_profile_fact_revisions_user_kind_valid_from_desc_idx"
ON "health_profile_fact_revisions" ("user_id", "kind", "valid_from" DESC);
CREATE INDEX "health_profile_fact_revisions_superseded_by_idx"
ON "health_profile_fact_revisions" ("superseded_by_revision_id");
ALTER TABLE "health_profile_fact_revisions"
ADD CONSTRAINT "health_profile_fact_revisions_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "health_profile_fact_revisions"
ADD CONSTRAINT "health_profile_fact_revisions_superseded_by_fkey"
FOREIGN KEY ("superseded_by_revision_id")
REFERENCES "health_profile_fact_revisions"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,42 @@
-- Custom metrics remain isolated unless the owner explicitly opts into
-- correlation discovery. Existing definitions retain the isolated default.
ALTER TABLE "custom_metrics"
ADD COLUMN "correlation_enabled" BOOLEAN NOT NULL DEFAULT false;
-- Stable, user-scoped identity plus the latest statistically accepted evidence.
-- Dismissal baselines are retained independently of subsequent recomputation so
-- immaterial movement remains suppressed while a material change can resurface.
CREATE TABLE "correlation_patterns" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"canonical_key" TEXT NOT NULL,
"family" TEXT NOT NULL,
"factor_key" TEXT NOT NULL,
"outcome_key" TEXT NOT NULL,
"lag_days" INTEGER NOT NULL,
"sample_size" INTEGER NOT NULL,
"effect_size" DOUBLE PRECISION NOT NULL,
"p_value" DOUBLE PRECISION NOT NULL,
"q_value" DOUBLE PRECISION,
"evidence_hash" TEXT NOT NULL,
"is_current" BOOLEAN NOT NULL DEFAULT true,
"last_computed_at" TIMESTAMP(3) NOT NULL,
"dismissed_at" TIMESTAMP(3),
"dismissed_evidence_hash" TEXT,
"dismissed_effect_size" DOUBLE PRECISION,
"dismissed_sample_size" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "correlation_patterns_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "correlation_patterns_user_id_canonical_key_key"
ON "correlation_patterns"("user_id", "canonical_key");
CREATE INDEX "correlation_patterns_user_id_family_is_current_idx"
ON "correlation_patterns"("user_id", "family", "is_current");
ALTER TABLE "correlation_patterns"
ADD CONSTRAINT "correlation_patterns_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,39 @@
-- Data-only, idempotent extension of the built-in binary mood-tag catalogue.
-- Mirrors migration 0101's deterministic-id and key-conflict taxonomy pattern.
INSERT INTO "mood_tags" (
"id", "category_id", "key", "label_key", "icon", "sort_order",
"is_active", "kind", "scale_min", "scale_max", "inverse", "user_id",
"label_encrypted"
)
VALUES (
'mt_caffeine', 'mtc_health', 'caffeine', 'mood.tag.caffeine', 'Coffee', 5,
true, 'BINARY', 1, 5, false, NULL, NULL
)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id",
"label_key" = EXCLUDED."label_key",
"icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order",
"kind" = EXCLUDED."kind",
"scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max",
"inverse" = EXCLUDED."inverse";
INSERT INTO "mood_tags" (
"id", "category_id", "key", "label_key", "icon", "sort_order",
"is_active", "kind", "scale_min", "scale_max", "inverse", "user_id",
"label_encrypted"
)
VALUES (
'mt_nicotine', 'mtc_health', 'nicotine', 'mood.tag.nicotine', 'Cigarette', 6,
true, 'BINARY', 1, 5, false, NULL, NULL
)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id",
"label_key" = EXCLUDED."label_key",
"icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order",
"kind" = EXCLUDED."kind",
"scale_min" = EXCLUDED."scale_min",
"scale_max" = EXCLUDED."scale_max",
"inverse" = EXCLUDED."inverse";
+170 -2
View File
@@ -770,6 +770,11 @@ model User {
// granularity from the measurement write hooks; WEEK/MONTH/YEAR
// are enqueued through pg-boss for the worker to fold.
measurementRollups MeasurementRollup[]
// v1.34.0 — per-(user, type, local day) cumulative hourly profile. The
// shape the nightly cumulative drain folds out of the per-sample rows
// immediately before deleting them, so a "typical by 21:00" comparison
// has a substrate for days older than the 36-hour grace window.
intradayCumulativeProfiles IntradayCumulativeProfile[]
// v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes.
// One row per (user, local date) the worker has nudged. Prevents
// a second push when the cron rechecks the same hour later in the
@@ -836,10 +841,11 @@ model User {
// marker); LabResult rows link to a Biomarker for the resolved range/unit.
biomarkers Biomarker[]
// v1.25.5 — user-defined custom metrics. A separate generic store
// (catalog + values), fully isolated from the closed MeasurementType
// system: not synced, not in FHIR, not in AI insights — log + chart only.
// (catalog + values), not synced and not in FHIR. Correlation discovery
// reads a metric only after the account explicitly opts it in.
customMetrics CustomMetric[]
customMetricEntries CustomMetricEntry[]
correlationPatterns CorrelationPattern[]
// v1.19.0 — illness / condition journal (born-gated by the module system).
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
// per-day timeline mirroring the CycleDayLog shape.
@@ -914,6 +920,7 @@ model User {
// v1.15.20 — user-authored "about me" context for the AI surfaces
// (1:1, encrypted free text). See `UserHealthProfile`.
healthProfile UserHealthProfile?
healthProfileFactRevisions HealthProfileFactRevision[]
// 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
@@ -1953,6 +1960,68 @@ model MeasurementRollup {
@@map("measurement_rollups")
}
// ─── Intraday cumulative profiles (v1.34.0) ────────────────
//
// The shape a cumulative day had, kept before the fold destroys it.
//
// A cumulative metric has no meaningful "latest reading" — a partial
// day's step count is a fragment, not a value — so the only honest
// comparison is against the same person's own typical total AT THE
// SAME LOCAL HOUR. That comparison needs an hourly substrate, and
// there was none: the nightly cumulative drain collapses per-sample
// Apple Health rows into one daily total after the 36-hour grace
// window and hard-deletes the originals in the same transaction.
// Today's curve was derivable; every older day's was already gone.
//
// So the drain now folds the rows it is about to delete into
// twenty-four running totals — one per local hour, index `h` carrying
// the total accumulated through the END of local hour `h` — and
// writes them here first. One compact row per user × type × local
// day, a few thousand rows a year across the supported types.
//
// This is a SIDECAR, never a reading. The canonical `stats:` daily
// total stays the measurement of record; nothing reads a value off
// this table that the measurement rows do not already carry. Days
// still inside the grace window have no row yet and are folded live
// from the raw rows on read, so the current day is always available.
model IntradayCumulativeProfile {
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
/// Local calendar day the profile describes (`YYYY-MM-DD`), in the
/// user's timezone at fold time. Lexicographically ordered, so a
/// trailing-window read and the retention prune are both range scans.
dateKey String @map("date_key") @db.VarChar(10)
/// Twenty-four running totals in canonical units — element `h` is the
/// value accumulated from local midnight through the END of local hour
/// `h`, so the array is non-decreasing and `[23]` equals `dayTotal`.
/// A DST day still has exactly 24 slots: the repeated hour folds into
/// its slot and a skipped hour repeats its predecessor's running total,
/// which keeps every stored day index-comparable with every other.
hourlyCumulative Float[] @map("hourly_cumulative")
/// The day's total — `hourlyCumulative[23]`, denormalised so a coverage
/// check never has to read the array.
dayTotal Float @map("day_total")
/// How many raw per-sample rows backed this profile. A day folded from
/// two rows is not a shape; the baseline reader uses this to refuse such
/// a day rather than let it drag the median.
sampleCount Int @map("sample_count")
/// The IANA zone the day was bucketed in. A user who later moves zones
/// leaves behind profiles cut on the old clock; carrying the zone lets
/// the reader drop the mismatched days instead of silently comparing
/// hours that mean different things.
timezone String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, type, dateKey])
@@index([userId, type, dateKey(sort: Desc)])
@@index([dateKey])
@@map("intraday_cumulative_profiles")
}
// ─── Workouts (v1.4.25 W8d) ────────────────────────────────
//
// HKWorkout-aligned typed columns for the analytics hot path + an
@@ -4894,6 +4963,9 @@ model CustomMetric {
decimals Int?
/// Optional free-text description ("what this measures"). Plaintext.
description String?
/// Explicit opt-in to expose this metric as a bounded behaviour channel in
/// correlation discovery. False keeps the metric isolated.
correlationEnabled Boolean @default(false) @map("correlation_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
@@ -4928,6 +5000,38 @@ model CustomMetricEntry {
@@map("custom_metric_entries")
}
/// Persisted identity and latest accepted evidence for a user-scoped
/// correlation finding. `canonicalKey` is derived from factor + outcome + lag;
/// the named fields remain queryable and make the identity portable.
model CorrelationPattern {
id String @id @default(cuid())
userId String @map("user_id")
canonicalKey String @map("canonical_key")
family String
factorKey String @map("factor_key")
outcomeKey String @map("outcome_key")
lagDays Int @map("lag_days")
sampleSize Int @map("sample_size")
effectSize Float @map("effect_size")
pValue Float @map("p_value")
qValue Float? @map("q_value")
evidenceHash String @map("evidence_hash")
isCurrent Boolean @default(true) @map("is_current")
lastComputedAt DateTime @map("last_computed_at")
dismissedAt DateTime? @map("dismissed_at")
dismissedEvidenceHash String? @map("dismissed_evidence_hash")
dismissedEffectSize Float? @map("dismissed_effect_size")
dismissedSampleSize Int? @map("dismissed_sample_size")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, canonicalKey])
@@index([userId, family, isCurrent])
@@map("correlation_patterns")
}
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
@@ -5319,6 +5423,10 @@ model UserHealthProfile {
// hints otherwise). Encrypted JSON string[]; the Coach composer
// renders them as tappable suggestion chips. NULL = none pending.
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
// Closed inclusion list for self-context sent to Coach and briefing prompts.
// The static default preserves every pre-existing prompt section for legacy
// rows without automatically opting future enum members in.
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -5327,6 +5435,66 @@ model UserHealthProfile {
@@map("user_health_profiles")
}
/// Sections of the health profile that may enter AI prompt context. This is an
/// inclusion list: a section absent from the array is neither decrypted nor
/// assembled for Coach or comprehensive briefings.
enum HealthProfileAiSection {
ABOUT_ME
CONDITIONS
ALLERGIES
COACH_FOCUS
FAMILY_HISTORY
SMOKING_STATUS
ALCOHOL_PATTERN
SHIFT_SCHEDULE
@@map("health_profile_ai_section")
}
/// The three structured profile facts whose answers affect an existing
/// interpretation path. The answer itself stays encrypted; this enum only
/// identifies which closed validation set must be applied after decryption.
enum HealthProfileFactKind {
SMOKING_STATUS
ALCOHOL_PATTERN
SHIFT_SCHEDULE
@@map("health_profile_fact_kind")
}
/// Provenance for an effective-dated fact revision.
enum HealthProfileFactProvenance {
USER_REPORTED
USER_CORRECTION
@@map("health_profile_fact_provenance")
}
/// Effective-dated structured health-profile fact. Each non-superseded chain
/// is half-open `[validFrom, validUntil)`, with exactly one current row per
/// user and fact kind. Corrections close the current row and mint a replacement
/// rather than overwriting encrypted history.
model HealthProfileFactRevision {
id String @id @default(cuid())
userId String @map("user_id")
kind HealthProfileFactKind
valueEncrypted Bytes @map("value_encrypted")
validFrom DateTime @map("valid_from") @db.Timestamptz(6)
validUntil DateTime? @map("valid_until") @db.Timestamptz(6)
provenance HealthProfileFactProvenance @default(USER_REPORTED)
supersededByRevisionId String? @map("superseded_by_revision_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
supersededBy HealthProfileFactRevision? @relation("HealthProfileFactSupersession", fields: [supersededByRevisionId], references: [id], onDelete: SetNull)
supersedes HealthProfileFactRevision[] @relation("HealthProfileFactSupersession")
@@unique([userId, kind, validFrom])
@@index([userId, kind, validFrom(sort: Desc)])
@@index([supersededByRevisionId])
@@map("health_profile_fact_revisions")
}
// 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
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.33.2";
/* @sw-version-fallback */ "v1.34.0";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+9
View File
@@ -385,6 +385,15 @@ async function main() {
);
}
// ───── HealthProfileFactRevision."valueEncrypted" (Bytes) ─────
results.push(
await rotateBytesColumn(
"HealthProfileFactRevision",
"valueEncrypted",
prisma.healthProfileFactRevision,
),
);
// ───── InsightNarrative."encryptedContent" (Bytes) ─────
results.push(
await rotateBytesColumn(
@@ -94,8 +94,8 @@ vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({
}),
}));
vi.mock("@/lib/analytics/health-score-fast-path", () => ({
computeUserHealthScoreFastPath: vi.fn().mockResolvedValue(null),
vi.mock("@/lib/analytics/score/reader", () => ({
computeUserHealthScore: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/analytics/correlations-fast-path", () => ({
@@ -159,8 +159,8 @@ beforeEach(async () => {
await import("@/lib/rollups/measurement-coverage");
const { computeBpInTargetFastPath } =
await import("@/lib/analytics/bp-in-target-fast-path");
const { computeUserHealthScoreFastPath } =
await import("@/lib/analytics/health-score-fast-path");
const { computeUserHealthScore } =
await import("@/lib/analytics/score/reader");
const { computeCorrelationHypothesesFastPath } =
await import("@/lib/analytics/correlations-fast-path");
const { computeSummariesSlice } =
@@ -179,9 +179,7 @@ beforeEach(async () => {
priorMonth: null,
priorYear: null,
});
(
computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue(null);
(computeUserHealthScore as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
).mockResolvedValue({ hypotheses: [] });
@@ -129,3 +129,188 @@ describe("every schema model has a backup verdict", () => {
).toEqual([]);
});
});
/**
* The second half of the guard: a `BACKED_UP` model either travels BOTH ways or
* is named as debt.
*
* The first half above proves every model has an opinion. It would pass
* unchanged while a model classified as carried was read into the payload and
* never written back — which is not a hypothetical, it is what happened to the
* nutrient day totals, to the custom mood tags, and to the custom cycle
* symptoms, three releases running.
*
* The check reads the declared writer and restore files rather than grepping
* for a route, BECAUSE the route delegates. A review that grepped only
* `restore/route.ts` concluded the cycle data was never restored and had to be
* retracted; `restoreCycleData` and `restoreProfileData` both live elsewhere.
* Following the helpers is the entire difficulty of this question, so the file
* list is declared next to the verdicts and checked to exist.
*/
import {
BACKUP_RESTORE_FILES,
BACKUP_WRITER_FILES,
COVERAGE_PENDING,
TWO_ENDED_MODELS,
} from "@/lib/export/backup-plan";
const REPO_ROOT = resolve(__dirname, "../..");
function read(relativePath: string): string {
return readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
}
/** `Measurement` → `measurement`, the Prisma delegate name. */
function delegateName(model: string): string {
return model[0].toLowerCase() + model.slice(1);
}
/**
* Relation fields on OTHER models whose type is `model`.
*
* A child rides its parent — `include: { schedules: true }` on the way out,
* `schedules: { create: [...] }` on the way back — so a delegate-only search
* would report every nested child as uncarried and the guard would be noise.
*/
function relationFieldNames(model: string): string[] {
const source = readFileSync(SCHEMA_PATH, "utf8");
const names = new Set<string>();
for (const [, body] of source.matchAll(/^model\s+\w+\s*\{([\s\S]*?)^\}/gm)) {
for (const line of body.split("\n")) {
const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line);
if (match && match[2] === model) names.add(match[1]);
}
}
return [...names];
}
const READ_OPS = /(findMany|findUnique|findFirst|findUniqueOrThrow|groupBy)/;
const WRITE_OPS = /(create|createMany|upsert|update|updateMany)/;
const READ_RELATION_OPS = /[{[t]/;
const WRITE_RELATION_OPS = /\{\s*create\b/;
/** Does any of `files` touch `model` through matching delegate and relation operations? */
function touches(
files: readonly string[],
model: string,
delegateOps: RegExp,
relationOps: RegExp,
): boolean {
const delegate = delegateName(model);
const relations = relationFieldNames(model);
return files.some((file) => {
// Whitespace-flattened so a call broken across lines still matches.
const source = read(file).replace(/\s+/g, " ");
for (const match of source.matchAll(
new RegExp(`\\.${delegate}\\s*\\.\\s*(\\w+)`, "g"),
)) {
if (delegateOps.test(match[1])) return true;
}
return relations.some((relation) =>
new RegExp(`\\b${relation}\\s*:\\s*(?:${relationOps.source})`).test(
source,
),
);
});
}
describe("every backed-up model travels both ways, or is named as debt", () => {
it("does not count a nested relation read as a restore write", () => {
expect(
touches(
["src/app/api/admin/backups/[id]/restore/route.ts"],
"User",
WRITE_OPS,
WRITE_RELATION_OPS,
),
).toBe(false);
});
it("declares writer and restore files that exist", () => {
for (const file of [...BACKUP_WRITER_FILES, ...BACKUP_RESTORE_FILES]) {
expect(() => read(file), `${file} is declared but missing`).not.toThrow();
}
// A grep of the restore ROUTE alone is the mistake this list prevents, so
// the list has to name more than the route.
expect(BACKUP_RESTORE_FILES.length).toBeGreaterThan(1);
});
it("splits BACKED_UP into exactly two-ended and pending, with no overlap", () => {
const backedUp = new Set<string>(BACKED_UP_MODELS);
const twoEnded = new Set(TWO_ENDED_MODELS);
const pending = new Set(Object.keys(COVERAGE_PENDING));
const both = [...twoEnded].filter((m) => pending.has(m));
expect(
both,
"a model cannot be both proven and pending — that is how a gap hides",
).toEqual([]);
const unaccounted = [...backedUp].filter(
(m) => !twoEnded.has(m) && !pending.has(m),
);
expect(
unaccounted,
"add each to TWO_ENDED_MODELS once its reader AND restore branch land, " +
"or to COVERAGE_PENDING with what an account loses meanwhile",
).toEqual([]);
const foreign = [...twoEnded, ...pending].filter((m) => !backedUp.has(m));
expect(
foreign,
"only a BACKED_UP model has two ends to have; these are not on that list",
).toEqual([]);
});
it("finds a payload reader for every model claimed two-ended", () => {
const missing = TWO_ENDED_MODELS.filter(
(model) =>
!touches(BACKUP_WRITER_FILES, model, READ_OPS, READ_RELATION_OPS),
);
expect(
missing,
"claimed carried, but no backup writer reads it — the restore branch has " +
"nothing feeding it and a restore silently rebuilds the account without it",
).toEqual([]);
});
it("finds a restore branch for every model claimed two-ended", () => {
const missing = TWO_ENDED_MODELS.filter(
(model) =>
!touches(BACKUP_RESTORE_FILES, model, WRITE_OPS, WRITE_RELATION_OPS),
);
expect(
missing,
"claimed carried, but nothing writes it back — the export is a file the " +
"restore reads past, which is the worst of both: the data is in the " +
"backup and not in the account",
).toEqual([]);
});
it("gives every pending model a reason that says what is lost", () => {
const thin = Object.entries(COVERAGE_PENDING)
.filter(([, reason]) => reason.trim().length < 60)
.map(([model]) => model);
expect(
thin,
"'not yet' is not a reason — say what an account loses, so the debt can " +
"be ordered by cost to a person rather than by ease of writing",
).toEqual([]);
});
it("proves the structured-profile models are on the covered side", () => {
// These account-owned rows are exported and restored explicitly rather
// than being treated as reconstructible data.
for (const model of [
"UserHealthProfile",
"HealthProfileFactRevision",
"CustomMetric",
"CustomMetricEntry",
]) {
expect(BACKED_UP_MODELS).toContain(model);
expect(TWO_ENDED_MODELS).toContain(model);
expect(COVERAGE_PENDING).not.toHaveProperty(model);
}
});
});
@@ -118,6 +118,10 @@ describe("data-wipe completeness", () => {
).toContain("CoachConversation");
});
it("wipes effective-dated health profile fact revisions", () => {
expect(WIPE_MODELS).toContain("HealthProfileFactRevision");
});
it("every model with a userId column is wiped or exempt with a reason", () => {
const userScoped = [...models]
.filter(([, shape]) => shape.scalars.includes("userId"))
File diff suppressed because it is too large. Load diff
@@ -372,6 +372,12 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "tombstoned-no-restore",
confirm: ["DeleteButton"],
},
{
file: "components/records/health-profile-facts-manager.tsx",
destroys: "the current value of one effective-dated health-profile fact",
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
},
{
file: "hooks/use-measurement-reminders.ts",
destroys: "a checkup or measurement reminder with its schedule",
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(90);
expect(ALL_LEAF_IDS.length).toBe(91);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(13);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14);
});
it("places every leaf in exactly one group", () => {
@@ -136,6 +136,7 @@ describe("report scope gating — structural guard", () => {
it("keeps every sensitive leaf in the fenced tier", () => {
for (const leaf of [
"MOOD",
"ANAMNESIS",
"CYCLE",
"FAMILY_HISTORY",
"PHQ9_SCORE",
@@ -102,7 +102,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"admin.overview.snapshotOfflineGeoOff",
"admin.overview.snapshotOfflineGeoOn",
"admin.webPushVapidTitle",
"dashboard.hero.scoreLabel",
"insights.healthScore.provenance.sources.appleHealth",
"measurements.sourceAppleHealth",
"onboarding.source.more.appleHealth",
@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* GUARD — no insights page passes an English word as a unit.
*
* A German reader was shown "13,387.6 steps" because `/insights/steps`
* handed the scaffold the literal string `steps`. Five of the eighteen unit
* literals under `src/app/insights` were English words: steps, years,
* flights, falls, and a "count" that was a poor label in any language. The
* other thirteen are language-neutral symbols — bpm, %, mmHg, m/s and their
* kin read the same in every locale, and "translating" one would be the
* opposite mistake.
*
* So the rule is not "route every unit through i18n", it is: a unit literal
* on an insights page must be a SYMBOL. A word travels as an i18n key
* (`unitKey`) and is resolved by the scaffold — and that key must resolve in
* every locale, which the `t()`-call-site coverage sweep cannot see because
* the key is a prop, not a call argument. Both halves are asserted here.
*/
const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const;
const INSIGHTS_PAGES_DIR = join(process.cwd(), "src/app/insights");
/**
* Unit strings that are language-neutral and therefore legitimately appear
* as literals. Every entry is a symbol, an SI abbreviation, or a composed
* one — nothing here changes when the reader's locale does. The empty string
* is the "this metric has no unit" case (pain score, waist-to-height ratio).
*/
const LANGUAGE_NEUTRAL_UNITS = new Set([
"",
"%",
"/10",
"bpm",
"dBA",
"kcal",
"kg/m²",
"m",
"m/s",
"mL/(kg·min)",
"min",
"mmHg",
"ms",
]);
/** Every `page.tsx` beneath `src/app/insights`, recursively. */
function insightsPageFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...insightsPageFiles(full));
} else if (entry === "page.tsx") {
out.push(full);
}
}
return out;
}
/**
* Every `unit="…"` / `yAxisUnit="…"` STRING literal in a source file.
* Expression props (`unit={glucoseUnit}`) resolve at runtime from a unit
* preference or an i18n call and are out of scope by construction.
*/
function unitLiterals(source: string): string[] {
return [...source.matchAll(/\b(?:yAxisUnit|unit)="([^"]*)"/g)].map(
(match) => match[1],
);
}
/** Every `unitKey="…"` literal in a source file. */
function unitKeys(source: string): string[] {
return [...source.matchAll(/\bunitKey="([^"]*)"/g)].map((match) => match[1]);
}
/** Resolve a dotted key against a loaded message bundle. */
function resolve(bundle: unknown, key: string): unknown {
let node = bundle;
for (const segment of key.split(".")) {
if (typeof node !== "object" || node === null) return undefined;
node = (node as Record<string, unknown>)[segment];
}
return node;
}
describe("insights unit-literal guard", () => {
const files = insightsPageFiles(INSIGHTS_PAGES_DIR);
const sources = files.map((file) => ({
path: file.slice(file.indexOf("src/")),
source: readFileSync(file, "utf8"),
}));
it("finds the insights pages to scan", () => {
// A broken walk would make every assertion below vacuously true.
expect(files.length).toBeGreaterThan(30);
});
it("scans real content — the known symbols are actually present", () => {
const seen = new Set(sources.flatMap((f) => unitLiterals(f.source)));
// If the matcher silently stopped matching, this fails rather than the
// offender list below going quietly empty.
for (const symbol of ["bpm", "mmHg", "%", "m/s"]) {
expect(seen.has(symbol), `expected to see unit="${symbol}"`).toBe(true);
}
});
it("passes only language-neutral unit symbols as literals", () => {
const offenders: string[] = [];
for (const { path, source } of sources) {
for (const literal of unitLiterals(source)) {
if (!LANGUAGE_NEUTRAL_UNITS.has(literal)) {
// A word-shaped unit belongs in `messages/*.json` behind `unitKey`.
offenders.push(`${path}: unit="${literal}"`);
}
}
}
expect(offenders).toEqual([]);
});
it("resolves every unitKey in all six locales", () => {
const keys = [...new Set(sources.flatMap((f) => unitKeys(f.source)))];
// The five words the pages actually carry.
expect(keys.length).toBeGreaterThanOrEqual(5);
const missing: string[] = [];
for (const locale of LOCALES) {
const bundle = JSON.parse(
readFileSync(join(process.cwd(), `messages/${locale}.json`), "utf8"),
);
for (const key of keys) {
const value = resolve(bundle, key);
if (typeof value !== "string" || value.trim() === "") {
missing.push(`${locale}: ${key}`);
}
}
}
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,765 @@
/**
* Per-envelope response/consumer guard for the failure visibility contracts
* exposed by integration status, admin status, and the environment overview.
*
* This deliberately does not sweep every response field in the application.
* Each producer declaration below is paired with the one web subtree that owns
* its presentation, and every assertion uses an envelope-qualified access. A
* generic name such as `state` or `failures` elsewhere cannot satisfy it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const STATUS_SCHEMA = join(ROOT, "src/lib/integrations/status.ts");
const JOB_FAILURE_SCHEMA = join(ROOT, "src/lib/jobs/job-failures.ts");
const INTEGRATION_ROUTE = join(
ROOT,
"src/app/api/integrations/status/route.ts",
);
const ADMIN_ROUTE = join(ROOT, "src/app/api/admin/status/route.ts");
const ENVIRONMENT_ROUTE = join(ROOT, "src/app/api/environment/route.ts");
const INTEGRATION_CONSUMER = join(
ROOT,
"src/components/settings/integrations/shared.tsx",
);
const ADMIN_CONSUMER = join(
ROOT,
"src/components/admin/system-status-section.tsx",
);
const ENVIRONMENT_CONSUMER = join(
ROOT,
"src/components/settings/environment-section.tsx",
);
function source(path: string): string {
return readFileSync(path, "utf8");
}
function parse(path: string): ts.SourceFile {
return ts.createSourceFile(
path,
source(path),
ts.ScriptTarget.Latest,
true,
path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
}
function expressionAccessPath(expression: ts.Expression): string | null {
if (ts.isIdentifier(expression)) return expression.text;
if (ts.isPropertyAccessExpression(expression)) {
const base = expressionAccessPath(expression.expression);
return base === null ? null : `${base}.${expression.name.text}`;
}
if (ts.isElementAccessExpression(expression)) {
const base = expressionAccessPath(expression.expression);
const key = expression.argumentExpression;
if (
base !== null &&
key &&
(ts.isStringLiteral(key) || ts.isNumericLiteral(key))
) {
return `${base}.${key.text}`;
}
return null;
}
if (
ts.isParenthesizedExpression(expression) ||
ts.isAsExpression(expression) ||
ts.isSatisfiesExpression(expression) ||
ts.isNonNullExpression(expression)
) {
return expressionAccessPath(expression.expression);
}
return null;
}
function executableAccesses(path: string): Set<string> {
const accesses = new Set<string>();
const file = parse(path);
function visit(node: ts.Node): void {
if (
ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node)
) {
const access = expressionAccessPath(node);
if (access !== null) accesses.add(access);
}
ts.forEachChild(node, visit);
}
visit(file);
return accesses;
}
function interfaceFields(path: string, interfaceName: string): string[] {
const file = parse(path);
const declaration = file.statements.find(
(statement): statement is ts.InterfaceDeclaration =>
ts.isInterfaceDeclaration(statement) &&
statement.name.text === interfaceName,
);
expect(
declaration,
`${interfaceName} must remain an interface in ${path}`,
).toBeDefined();
return declaration!.members.flatMap((member) => {
if (!ts.isPropertySignature(member) || member.name === undefined) return [];
return [member.name.getText(file).replace(/^['"]|['"]$/g, "")];
});
}
function stringUnionMembers(path: string, typeName: string): string[] {
const file = parse(path);
const declaration = file.statements.find(
(statement): statement is ts.TypeAliasDeclaration =>
ts.isTypeAliasDeclaration(statement) && statement.name.text === typeName,
);
expect(
declaration,
`${typeName} must remain a type alias in ${path}`,
).toBeDefined();
expect(ts.isUnionTypeNode(declaration!.type)).toBe(true);
return (declaration!.type as ts.UnionTypeNode).types.flatMap((member) =>
ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal)
? [member.literal.text]
: [],
);
}
function propertyName(
name: ts.PropertyName,
file: ts.SourceFile,
): string | null {
if (ts.isComputedPropertyName(name)) return null;
return name.getText(file).replace(/^['"]|['"]$/g, "");
}
function namedFunctionReturnFields(
file: ts.SourceFile,
functionName: string,
): string[] {
let declaration: ts.FunctionDeclaration | undefined;
function find(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name?.text === functionName) {
expect(
declaration,
`${functionName} must have one declaration`,
).toBeUndefined();
declaration = node;
}
ts.forEachChild(node, find);
}
find(file);
expect(
declaration?.body,
`${functionName} must remain a declared function with a body`,
).toBeDefined();
const branches: string[][] = [];
function collectReturns(node: ts.Node): void {
if (ts.isReturnStatement(node)) {
expect(
node.expression && ts.isObjectLiteralExpression(node.expression),
`${functionName} must return an object literal`,
).toBe(true);
if (node.expression && ts.isObjectLiteralExpression(node.expression)) {
branches.push(
node.expression.properties.flatMap((property) => {
expect(
ts.isPropertyAssignment(property) ||
ts.isShorthandPropertyAssignment(property),
`${functionName} contains unsupported return syntax: ${property.getText(file)}`,
).toBe(true);
if (
!ts.isPropertyAssignment(property) &&
!ts.isShorthandPropertyAssignment(property)
) {
return [];
}
const name = propertyName(property.name, file);
expect(
name,
`${functionName} has a computed response field`,
).not.toBeNull();
return name === null ? [] : [name];
}),
);
}
return;
}
ts.forEachChild(node, collectReturns);
}
collectReturns(declaration!.body!);
expect(
branches.length,
`${functionName} must return a payload`,
).toBeGreaterThan(0);
for (const [index, branch] of branches.entries()) {
expect(
branch.sort(),
`${functionName} return branch ${index + 1} must preserve one shape`,
).toEqual([...branches[0]].sort());
}
return branches[0];
}
function namedFunctionReturnLineage(
file: ts.SourceFile,
functionName: string,
): Record<string, string | null> {
let declaration: ts.FunctionDeclaration | undefined;
function find(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name?.text === functionName) {
expect(
declaration,
`${functionName} must have one declaration`,
).toBeUndefined();
declaration = node;
}
ts.forEachChild(node, find);
}
find(file);
expect(declaration?.body).toBeDefined();
const branches: Array<Record<string, string | null>> = [];
function collectReturns(node: ts.Node): void {
if (ts.isReturnStatement(node)) {
expect(
node.expression && ts.isObjectLiteralExpression(node.expression),
`${functionName} must return an object literal`,
).toBe(true);
if (node.expression && ts.isObjectLiteralExpression(node.expression)) {
const lineage: Record<string, string | null> = {};
for (const property of node.expression.properties) {
expect(
ts.isPropertyAssignment(property) ||
ts.isShorthandPropertyAssignment(property),
`${functionName} contains unsupported return syntax: ${property.getText(file)}`,
).toBe(true);
if (ts.isPropertyAssignment(property)) {
const name = propertyName(property.name, file);
expect(name).not.toBeNull();
if (name !== null) {
lineage[name] = expressionAccessPath(property.initializer);
}
} else if (ts.isShorthandPropertyAssignment(property)) {
lineage[property.name.text] = property.name.text;
}
}
branches.push(lineage);
}
return;
}
ts.forEachChild(node, collectReturns);
}
collectReturns(declaration!.body!);
expect(branches.length).toBeGreaterThan(0);
for (const [index, branch] of branches.entries()) {
expect(
branch,
`${functionName} return branch ${index + 1} must preserve one lineage`,
).toEqual(branches[0]);
}
return branches[0];
}
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression;
while (
ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isNonNullExpression(current)
) {
current = current.expression;
}
return current;
}
function integrationPublishFlow(path: string): {
publishedFields: string[];
publishedSnapshots: string[];
entries: number;
} {
const file = parse(path);
const publishedFields = namedFunctionReturnFields(file, "publish");
const publishedLineage = namedFunctionReturnLineage(file, "publish");
expect(Object.keys(publishedLineage).sort()).toEqual(
[...publishedFields].sort(),
);
for (const field of publishedFields) {
expect(
publishedLineage[field],
`publish.${field} must come from snapshot.${field}`,
).toBe(`snapshot.${field}`);
}
const resolvedFields = namedFunctionReturnFields(file, "resolve");
expect(
publishedFields.filter((field) => resolvedFields.includes(field)),
"resolve(...) must not override a field supplied by publish(...)",
).toEqual([]);
let apiSuccessBranches = 0;
let entries = 0;
const publishedSnapshots: string[] = [];
function visit(node: ts.Node): void {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "apiSuccess"
) {
apiSuccessBranches += 1;
const payload = node.arguments[0];
expect(
payload && ts.isObjectLiteralExpression(payload),
"integration status apiSuccess payload must remain an object literal",
).toBe(true);
if (!payload || !ts.isObjectLiteralExpression(payload)) return;
const threshold = payload.properties.find(
(property) =>
ts.isPropertyAssignment(property) &&
propertyName(property.name, file) === "threshold",
);
expect(
threshold && ts.isPropertyAssignment(threshold),
"integration status must expose threshold",
).toBe(true);
if (threshold && ts.isPropertyAssignment(threshold)) {
expect(
ts.isCallExpression(threshold.initializer) &&
ts.isIdentifier(threshold.initializer.expression) &&
threshold.initializer.expression.text ===
"getPersistentFailureThreshold",
"threshold must come from getPersistentFailureThreshold()",
).toBe(true);
}
const integrations = payload.properties.find(
(property) =>
ts.isPropertyAssignment(property) &&
propertyName(property.name, file) === "integrations",
);
expect(
integrations && ts.isPropertyAssignment(integrations),
"integration status must expose integrations",
).toBe(true);
if (!integrations || !ts.isPropertyAssignment(integrations)) return;
expect(ts.isArrayLiteralExpression(integrations.initializer)).toBe(true);
if (!ts.isArrayLiteralExpression(integrations.initializer)) return;
for (const element of integrations.initializer.elements) {
const entry = unwrapExpression(element);
expect(
ts.isObjectLiteralExpression(entry),
"every integration response entry must remain an object literal",
).toBe(true);
if (!ts.isObjectLiteralExpression(entry)) continue;
entries += 1;
let publishSpreads = 0;
let publishSeen = false;
for (const property of entry.properties) {
if (ts.isSpreadAssignment(property)) {
expect(
ts.isCallExpression(property.expression) &&
ts.isIdentifier(property.expression.expression),
`integration entry has unsupported spread: ${property.getText(file)}`,
).toBe(true);
if (
!ts.isCallExpression(property.expression) ||
!ts.isIdentifier(property.expression.expression)
) {
continue;
}
const spreadName = property.expression.expression.text;
expect(
spreadName === "publish" || spreadName === "resolve",
`integration entry has unknown spread ${spreadName}(...)`,
).toBe(true);
if (spreadName === "publish") {
publishSpreads += 1;
publishSeen = true;
expect(
property.expression.arguments.length === 1 &&
ts.isIdentifier(property.expression.arguments[0]),
"publish(...) must receive one provider snapshot variable",
).toBe(true);
if (
property.expression.arguments.length === 1 &&
ts.isIdentifier(property.expression.arguments[0])
) {
publishedSnapshots.push(property.expression.arguments[0].text);
}
}
continue;
}
expect(
ts.isPropertyAssignment(property) ||
ts.isShorthandPropertyAssignment(property),
`integration entry has unsupported property syntax: ${property.getText(file)}`,
).toBe(true);
if (
ts.isPropertyAssignment(property) ||
ts.isShorthandPropertyAssignment(property)
) {
const name = propertyName(property.name, file);
expect(
name,
"integration entry has a computed field",
).not.toBeNull();
if (publishSeen && name !== null) {
expect(
publishedFields,
`${name} overrides publish(...) later in the same entry`,
).not.toContain(name);
}
}
}
expect(
publishSpreads,
"every integration response entry must spread publish(...) exactly once",
).toBe(1);
}
}
ts.forEachChild(node, visit);
}
visit(file);
expect(apiSuccessBranches).toBeGreaterThan(0);
expect(entries).toBeGreaterThan(0);
return { publishedFields, publishedSnapshots, entries };
}
function assertBucketConsumerLineage(
path: string,
kinds: readonly string[],
): void {
const file = parse(path);
const declaration = file.statements.find(
(statement): statement is ts.FunctionDeclaration =>
ts.isFunctionDeclaration(statement) &&
statement.name?.text === "pillFailurePropsFor",
);
expect(declaration?.body).toBeDefined();
let bucketInitializer: string | null = null;
const accesses = new Set<string>();
function visit(node: ts.Node): void {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === "buckets" &&
node.initializer
) {
expect(bucketInitializer).toBeNull();
bucketInitializer = expressionAccessPath(node.initializer);
}
if (
ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node)
) {
const access = expressionAccessPath(node);
if (access !== null) accesses.add(access);
}
ts.forEachChild(node, visit);
}
visit(declaration!.body!);
expect(
bucketInitializer,
"buckets must be read from the integration status response",
).toBe("status.consecutiveFailuresByKind");
for (const kind of kinds) {
expect(
accesses,
`pillFailurePropsFor must read buckets.${kind} from that response value`,
).toContain(`buckets.${kind}`);
}
}
interface ConsumerBinding {
file: string;
accesses: readonly string[];
}
function assertBindings(
leaves: readonly string[],
bindings: Readonly<Record<string, ConsumerBinding>>,
): void {
expect(
Object.keys(bindings).sort(),
"consumer bindings must exactly match the producer leaves",
).toEqual([...leaves].sort());
const accessesByFile = new Map<string, Set<string>>();
for (const leaf of leaves) {
const binding = bindings[leaf];
expect(binding, `${leaf} has no envelope-owned web consumer`).toBeDefined();
const accesses =
accessesByFile.get(binding.file) ?? executableAccesses(binding.file);
accessesByFile.set(binding.file, accesses);
for (const access of binding.accesses) {
expect(
accesses.has(access),
`${leaf} no longer reads ${access} in ${binding.file}`,
).toBe(true);
}
}
}
function assertJsxHandoff(
path: string,
componentName: string,
propName: string,
access: string,
): void {
const file = parse(path);
const handoffs: Array<string | null> = [];
function visit(node: ts.Node): void {
if (
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
ts.isIdentifier(node.tagName) &&
node.tagName.text === componentName
) {
const attribute = node.attributes.properties.find(
(property) =>
ts.isJsxAttribute(property) &&
ts.isIdentifier(property.name) &&
property.name.text === propName,
);
expect(
attribute && ts.isJsxAttribute(attribute),
`${componentName} must receive ${propName}`,
).toBe(true);
if (
attribute &&
ts.isJsxAttribute(attribute) &&
attribute.initializer &&
ts.isJsxExpression(attribute.initializer) &&
attribute.initializer.expression
) {
handoffs.push(expressionAccessPath(attribute.initializer.expression));
} else {
handoffs.push(null);
}
}
ts.forEachChild(node, visit);
}
visit(file);
expect(
handoffs,
`${componentName}.${propName} must receive ${access} exactly once`,
).toEqual([access]);
}
describe("integration and background-failure response consumer guard", () => {
function assertEnvironmentFailureLineage(path: string): void {
const file = parse(path);
const bindings: Array<{
declaration: ts.VariableDeclaration;
index: number;
}> = [];
function findBinding(node: ts.Node): void {
if (
ts.isVariableDeclaration(node) &&
ts.isArrayBindingPattern(node.name)
) {
const index = node.name.elements.findIndex(
(element) =>
ts.isBindingElement(element) &&
ts.isIdentifier(element.name) &&
element.name.text === "lastFetchFailure",
);
if (index >= 0) bindings.push({ declaration: node, index });
}
ts.forEachChild(node, findBinding);
}
findBinding(file);
expect(
bindings,
"lastFetchFailure must have one Promise.all binding",
).toHaveLength(1);
const { declaration, index } = bindings[0];
let initializer = declaration.initializer;
if (initializer && ts.isAwaitExpression(initializer)) {
initializer = initializer.expression;
}
expect(
initializer &&
ts.isCallExpression(initializer) &&
ts.isPropertyAccessExpression(initializer.expression) &&
ts.isIdentifier(initializer.expression.expression) &&
initializer.expression.expression.text === "Promise" &&
initializer.expression.name.text === "all" &&
initializer.arguments.length === 1 &&
ts.isArrayLiteralExpression(initializer.arguments[0]),
"lastFetchFailure must be bound from Promise.all([...])",
).toBe(true);
if (
!initializer ||
!ts.isCallExpression(initializer) ||
initializer.arguments.length !== 1 ||
!ts.isArrayLiteralExpression(initializer.arguments[0])
) {
return;
}
const sourceExpression = initializer.arguments[0].elements[index];
expect(
sourceExpression &&
ts.isCallExpression(sourceExpression) &&
ts.isIdentifier(sourceExpression.expression) &&
sourceExpression.expression.text === "readQueueFailureForUser" &&
sourceExpression.arguments.length === 2 &&
expressionAccessPath(sourceExpression.arguments[0]) ===
"ENVIRONMENT_FETCH_QUEUE" &&
expressionAccessPath(sourceExpression.arguments[1]) === "user.id",
"lastFetchFailure must come from the user's environment fetch queue",
).toBe(true);
const responseSources: Array<string | null> = [];
function findResponse(node: ts.Node): void {
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "apiSuccess" &&
node.arguments[0] &&
ts.isObjectLiteralExpression(node.arguments[0])
) {
for (const property of node.arguments[0].properties) {
if (
ts.isShorthandPropertyAssignment(property) &&
property.name.text === "lastFetchFailure"
) {
responseSources.push(property.name.text);
} else if (
ts.isPropertyAssignment(property) &&
propertyName(property.name, file) === "lastFetchFailure"
) {
responseSources.push(expressionAccessPath(property.initializer));
}
}
}
ts.forEachChild(node, findResponse);
}
findResponse(file);
expect(
responseSources,
"environment response must hand off lastFetchFailure exactly once",
).toEqual(["lastFetchFailure"]);
}
it("pairs every integration failure-count leaf with the shared card adapter", () => {
const kinds = stringUnionMembers(STATUS_SCHEMA, "FailureKind");
expect(kinds).toEqual(["transient", "reauth_required", "persistent"]);
const flow = integrationPublishFlow(INTEGRATION_ROUTE);
const expectedPublishFields = interfaceFields(
STATUS_SCHEMA,
"IntegrationStatusSnapshot",
).filter((field) => field !== "failingSince");
expect(flow.publishedFields.sort()).toEqual(expectedPublishFields.sort());
expect(flow.publishedFields).toContain("consecutiveFailuresByKind");
expect(flow.entries).toBe(
stringUnionMembers(STATUS_SCHEMA, "IntegrationKey").length,
);
const expectedSnapshots = stringUnionMembers(
STATUS_SCHEMA,
"IntegrationKey",
).map((key) => {
const camelKey = key.replace(/-([a-z])/g, (_, letter: string) =>
letter.toUpperCase(),
);
return `${camelKey}Status`;
});
expect(
flow.publishedSnapshots.sort(),
"every provider snapshot must be published exactly once",
).toEqual(expectedSnapshots.sort());
assertBucketConsumerLineage(INTEGRATION_CONSUMER, kinds);
const leaves = [
"threshold",
...kinds.map((kind) => `consecutiveFailuresByKind.${kind}`),
];
assertBindings(leaves, {
threshold: {
file: INTEGRATION_CONSUMER,
accesses: ["envelope.threshold"],
},
"consecutiveFailuresByKind.transient": {
file: INTEGRATION_CONSUMER,
accesses: ["buckets.transient"],
},
"consecutiveFailuresByKind.reauth_required": {
file: INTEGRATION_CONSUMER,
accesses: ["buckets.reauth_required"],
},
"consecutiveFailuresByKind.persistent": {
file: INTEGRATION_CONSUMER,
accesses: ["buckets.persistent"],
},
});
});
it("pairs every admin failing-jobs leaf with the failing-jobs card", () => {
const queueFields = interfaceFields(JOB_FAILURE_SCHEMA, "FailingQueue");
const route = source(ADMIN_ROUTE);
expect(route).toMatch(/failingJobs:\s*\n?\s*failingJobs === null/);
expect(route).toMatch(
/\{\s*windowHours:\s*JOB_FAILURE_WINDOW_HOURS,\s*queues:\s*failingJobs\s*\}/,
);
assertJsxHandoff(
join(ROOT, "src/components/admin/system-status-section.tsx"),
"FailingJobsCard",
"failingJobs",
"status.failingJobs",
);
const leaves = [
"failingJobs.windowHours",
...queueFields.map((field) => `failingJobs.queues.${field}`),
];
assertBindings(leaves, {
"failingJobs.windowHours": {
file: ADMIN_CONSUMER,
accesses: ["failingJobs.windowHours"],
},
"failingJobs.queues.queue": {
file: ADMIN_CONSUMER,
accesses: ["failingJobs.queues", "queue.queue"],
},
"failingJobs.queues.failures": {
file: ADMIN_CONSUMER,
accesses: ["failingJobs.queues", "queue.failures"],
},
"failingJobs.queues.lastFailedAt": {
file: ADMIN_CONSUMER,
accesses: ["failingJobs.queues", "queue.lastFailedAt"],
},
"failingJobs.queues.lastError": {
file: ADMIN_CONSUMER,
accesses: ["failingJobs.queues", "queue.lastError"],
},
});
});
it("pairs every account-scoped queue-failure leaf with environment settings", () => {
const failureFields = interfaceFields(
JOB_FAILURE_SCHEMA,
"QueueFailureForUser",
);
assertEnvironmentFailureLineage(ENVIRONMENT_ROUTE);
const leaves = failureFields.map((field) => `lastFetchFailure.${field}`);
assertBindings(leaves, {
"lastFetchFailure.lastFailedAt": {
file: ENVIRONMENT_CONSUMER,
accesses: ["data.lastFetchFailure.lastFailedAt"],
},
"lastFetchFailure.failures": {
file: ENVIRONMENT_CONSUMER,
accesses: ["data.lastFetchFailure.failures"],
},
});
});
});
@@ -37,6 +37,13 @@ const BACKUP_BUILDER = join(
"export",
"full-backup-payload.ts",
);
const PROFILE_BACKUP_BUILDER = join(
ROOT,
"src",
"lib",
"export",
"profile-backup.ts",
);
/**
* Columns the backup is correct to omit, each with the reason.
@@ -110,6 +117,26 @@ function serialisedFields(source: string): Set<string> {
);
}
function interfaceFields(source: string, name: string): Set<string> {
const start = source.indexOf(`export interface ${name} {`);
expect(start, `interface ${name} not found`).toBeGreaterThan(-1);
const end = source.indexOf("\n}", start);
expect(end).toBeGreaterThan(start);
return new Set(
[...source.slice(start, end).matchAll(/^ {2}([a-zA-Z_$][\w$]*)[?:]/gm)].map(
(match) => match[1],
),
);
}
function emittedModelFields(source: string, variable: string): Set<string> {
return new Set(
[
...source.matchAll(new RegExp(`${variable}\\.([a-zA-Z_$][\\w$]*)`, "g")),
].map((match) => match[1]),
);
}
describe("measurement backup completeness", () => {
const schema = readFileSync(SCHEMA, "utf8");
const source = readFileSync(BACKUP_BUILDER, "utf8");
@@ -162,3 +189,33 @@ describe("measurement backup completeness", () => {
}
});
});
describe("health profile backup completeness", () => {
const schema = readFileSync(SCHEMA, "utf8");
const source = readFileSync(PROFILE_BACKUP_BUILDER, "utf8");
const models = declaredModels(schema);
const excluded = new Set(["userId"]);
it.each([
["UserHealthProfile", "HealthProfileBackupEntry", "profileRow"],
["HealthProfileFactRevision", "HealthProfileFactBackupEntry", "fact"],
])(
"%s columns are represented and emitted by the profile backup",
(model, wireInterface, variable) => {
const columns = scalarFields(modelBlock(schema, model), models).filter(
(column) => !excluded.has(column),
);
const wire = interfaceFields(source, wireInterface);
const emitted = emittedModelFields(source, variable);
expect(
columns.filter((column) => !wire.has(column)),
`${wireInterface} omits stored ${model} columns`,
).toEqual([]);
expect(
columns.filter((column) => !emitted.has(column)),
`${model} columns never reach either serialized arm`,
).toEqual([]);
},
);
});
@@ -0,0 +1,567 @@
/**
* G3 — the success affordance is owned by the outcome module, everywhere.
*
* #640 established the rule inside the import panel: a write that wrote
* nothing must not read as a success. #650 widened it to the medication
* intake import. This widens it to the whole component tree, because the same
* defect had been sitting in seven more surfaces the whole time — six
* integration cards and the lab-OCR dialog reported "success" off `res.ok`,
* so a sync that imported nothing because every reading was refused rendered
* a green tick and a count.
*
* What this asserts:
* 1. Every `CheckCircle2` / `text-success` / `toast.success` occurrence
* under `src/components` is either inside the shared presentation module
* (`src/components/outcome/`) or pinned in `PINNED_AFFORDANCES` with an
* exact per-file count. A name-only allowlist would let a listed file add
* a SECOND, dishonest tick without anyone noticing, which is why the
* counts are pinned rather than the filenames.
* 2. No pinned entry has gone stale — a file that no longer carries the
* marker, or no longer exists, fails here rather than rotting silently.
* This is the guard-of-the-guard: if the scanner ever stopped matching,
* every entry would go stale at once and this is what would say so.
* 3. Every surface that reports a write routes its result line through the
* shared outcome module.
*
* HOW WEAK THIS GUARD IS, stated plainly: this is the weakest guard in the
* release and it is not complete. It matches three literal tokens. It is
* evadable by synonym — `text-emerald-500`, a raw checkmark glyph, an
* `<svg>` copy of the icon, `toast(...)` with a success-looking variant, or a
* component that wraps any of them. Nothing here proves a surface is honest;
* it proves a surface did not reach for these three specific affordances
* without being listed. It also says nothing about `src/app`, which is out of
* scope by construction (the counted noise floor was measured over
* `src/components`). Treat a green run as "the known evasions are closed",
* not as "no surface can lie".
*
* Its forced failures, each watched fail before this was committed and then
* reverted:
* - append `toast.success("probe")` to `src/components/ui/alert-dialog.tsx`
* (no entry) -> red on the first case, naming the file;
* - append a second `CheckCircle2` reference to
* `src/components/onboarding/done-screen.tsx` -> red on the second case:
* "CheckCircle2 pinned at 2, found 3";
* - append a second `text-success` literal to
* `src/components/settings/test-connection-button.tsx`, which is pinned at
* exactly 1 -> red: "text-success pinned at 1, found 2".
*/
import { describe, it, expect } from "vitest";
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { join, posix, relative, sep } from "node:path";
const REPO_ROOT = join(__dirname, "..", "..");
const COMPONENT_ROOT = join(REPO_ROOT, "src", "components");
/** The tick component, the success colour token, the success toast. */
const SUCCESS_MARKERS = [
"CheckCircle2",
"text-success",
"toast.success",
] as const;
type SuccessMarker = (typeof SUCCESS_MARKERS)[number];
/**
* The one module allowed to reach for the affordance. `WrittenOutcomeLine`
* and `toastWrittenOutcome` bind it to the `success` key of the outcome table
* and nothing else can get at it.
*/
const PRESENTATION_DIR = "src/components/outcome/";
/**
* Every OTHER file under `src/components` that mentions a marker, with the
* exact number of times it does. Seeded from the tree as it stands; an entry
* is not an endorsement, it is a record. Reducing one of these to zero is
* always welcome — delete the entry in the same commit.
*/
const PINNED_AFFORDANCES: Record<
string,
Partial<Record<SuccessMarker, number>>
> = {
"src/components/admin/_shared.tsx": { "text-success": 2, "toast.success": 1 },
"src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 },
"src/components/admin/api-token-overview-section.tsx": { "text-success": 1 },
"src/components/admin/app-log-preview-section.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/admin/assistant-section.tsx": { "toast.success": 1 },
"src/components/admin/backups-section.tsx": { "toast.success": 4 },
"src/components/admin/central-codex-section.tsx": { "toast.success": 2 },
"src/components/admin/danger-zone-section.tsx": { "text-success": 1 },
"src/components/admin/encryption-section.tsx": {
"text-success": 1,
"toast.success": 1,
},
"src/components/admin/glitchtip-section.tsx": { "toast.success": 1 },
"src/components/admin/invite-tokens-section.tsx": {
"text-success": 2,
"toast.success": 3,
},
"src/components/admin/login-overview-section.tsx": {
CheckCircle2: 2,
"text-success": 2,
"toast.success": 1,
},
"src/components/admin/module-availability-section.tsx": {
"toast.success": 1,
},
"src/components/admin/recent-audit-preview.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/admin/reminders-section.tsx": {
CheckCircle2: 2,
"text-success": 4,
"toast.success": 2,
},
"src/components/admin/system-status-section.tsx": { "text-success": 6 },
"src/components/admin/system-status-summary.tsx": { "text-success": 3 },
"src/components/admin/umami-section.tsx": { "toast.success": 1 },
"src/components/admin/user-management-section.tsx": {
"text-success": 2,
"toast.success": 2,
},
"src/components/admin/version-tile-section.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/admin/web-push-vapid-section.tsx": { "toast.success": 1 },
"src/components/charts/medication-compliance-chart.tsx": {
"text-success": 1,
},
"src/components/custom-metrics/custom-metric-detail.tsx": {
"toast.success": 1,
},
"src/components/custom-metrics/custom-metric-entry-form.tsx": {
"toast.success": 1,
},
"src/components/custom-metrics/custom-metric-form.tsx": {
"toast.success": 1,
},
"src/components/custom-metrics/custom-metric-history-list.tsx": {
"toast.success": 2,
},
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
"src/components/dashboard/medication-intake-quick-add.tsx": {
"toast.success": 1,
},
"src/components/dashboard/range-display.tsx": { "text-success": 2 },
"src/components/documents/document-card.tsx": { "text-success": 1 },
"src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 },
"src/components/documents/documents-view.tsx": { "toast.success": 3 },
"src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 },
"src/components/illness/episode-menu.tsx": {
CheckCircle2: 2,
"toast.success": 1,
},
"src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 },
"src/components/insights/coach-panel/history-rail.tsx": {
"toast.success": 1,
},
"src/components/insights/coach-panel/plan-proposal-card.tsx": {
"text-success": 1,
},
"src/components/insights/coach-panel/reminder-suggestion-card.tsx": {
"text-success": 1,
},
"src/components/insights/coach-panel/self-context-adopt-offer.tsx": {
"text-success": 1,
},
"src/components/insights/coach-panel/suggested-action-card.tsx": {
"text-success": 1,
},
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
"src/components/insights/mood/mood-factor-metric-crosstab.tsx": {
"text-success": 1,
},
"src/components/insights/mood/mood-tag-influence.tsx": { "text-success": 1 },
"src/components/insights/mood/mood-tag-metric-crosstab.tsx": {
"text-success": 1,
},
"src/components/insights/nutrients/water-quick-add-sheet.tsx": {
"toast.success": 2,
},
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
"src/components/insights/recommendation-feedback.tsx": { "text-success": 3 },
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
"src/components/labs/lab-form.tsx": { "toast.success": 1 },
"src/components/labs/lab-history-list.tsx": { "toast.success": 3 },
"src/components/measurement-reminders/vorsorge-dashboard-card.tsx": {
CheckCircle2: 2,
},
"src/components/measurement-reminders/vorsorge-section.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/measurements/measurement-form.tsx": { "toast.success": 1 },
"src/components/measurements/measurement-list.tsx": { "toast.success": 3 },
"src/components/medications/card-parts/medication-status-pill.tsx": {
"text-success": 1,
},
"src/components/medications/dose-history-add-dialog.tsx": {
"toast.success": 1,
},
"src/components/medications/dose-history-ledger.tsx": {
"text-success": 2,
"toast.success": 4,
},
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
"src/components/medications/intake-history-editable.tsx": {
"toast.success": 2,
},
"src/components/medications/intake-history-list-v2.tsx": {
"text-success": 2,
},
"src/components/medications/research-mode-acknowledgment-dialog.tsx": {
"toast.success": 1,
},
"src/components/medications/scheduling/schedule-history-timeline.tsx": {
"toast.success": 2,
},
"src/components/medications/scheduling/schedule-times-editor.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/api-tokens-row.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/destructive-zone-section.tsx": {
"toast.success": 4,
},
"src/components/medications/sections/inventory-dialogs.tsx": {
"toast.success": 3,
},
"src/components/medications/sections/inventory-section.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/notifications-section.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/phase-config-sheet.tsx": {
"toast.success": 1,
},
"src/components/medications/sections/settings-section.tsx": {
"toast.success": 2,
},
"src/components/medications/take-all-due.ts": { "toast.success": 1 },
"src/components/medications/use-medication-intake.ts": { "toast.success": 4 },
"src/components/medications/wizard/medication-wizard-dialog.tsx": {
"toast.success": 1,
},
"src/components/mood/manage/archived-tags-card.tsx": { "toast.success": 2 },
"src/components/mood/manage/tag-editor-sheet.tsx": { "toast.success": 2 },
"src/components/mood/manage/tag-groups-card.tsx": { "toast.success": 4 },
"src/components/mood/manage/tag-manager-card.tsx": { "toast.success": 2 },
"src/components/mood/mood-form.tsx": { "toast.success": 1 },
"src/components/mood/mood-list.tsx": { "toast.success": 3 },
"src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 },
"src/components/onboarding/source-card-grid.tsx": { CheckCircle2: 2 },
"src/components/records/allergy-form.tsx": { "toast.success": 1 },
"src/components/records/allergy-manager.tsx": { "toast.success": 1 },
"src/components/records/conditions-manager.tsx": { "toast.success": 1 },
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
"src/components/settings/_info-tile.tsx": { "text-success": 1 },
"src/components/settings/about-me-section.tsx": { "toast.success": 1 },
"src/components/settings/account-section/avatar-section.tsx": {
"text-success": 1,
},
"src/components/settings/account-section/index.tsx": { "text-success": 2 },
"src/components/settings/advanced-section.tsx": { "text-success": 2 },
"src/components/settings/ai/ai-insights-card.tsx": { "text-success": 1 },
"src/components/settings/ai/anthropic-provider-form.tsx": {
"text-success": 1,
},
"src/components/settings/ai/codex-provider-form.tsx": { "text-success": 2 },
"src/components/settings/ai/compat-provider-form.tsx": { "text-success": 1 },
"src/components/settings/ai/fallback-chain-card.tsx": { "text-success": 1 },
"src/components/settings/ai/local-provider-form.tsx": { "text-success": 1 },
"src/components/settings/ai/openai-provider-form.tsx": { "text-success": 1 },
"src/components/settings/ai/response-timeout-card.tsx": { "text-success": 1 },
"src/components/settings/ai/runtime-actions-row.tsx": { "text-success": 2 },
"src/components/settings/api-section.tsx": { "text-success": 3 },
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
"src/components/settings/dashboard-layout-section.tsx": {
"toast.success": 2,
},
"src/components/settings/email-card.tsx": { "text-success": 1 },
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
"src/components/settings/insights-pill-order-section.tsx": {
"toast.success": 1,
},
"src/components/settings/integration-status-pill.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/settings/integrations/connections-panel.tsx": {
"toast.success": 2,
},
"src/components/settings/integrations/fitbit-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/google-health-card.tsx": {
"text-success": 2,
},
"src/components/settings/integrations/nightscout-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/oauth-provider-card.tsx": {
"text-success": 1,
},
"src/components/settings/integrations/whoop-card.tsx": { "text-success": 2 },
"src/components/settings/integrations/withings-card.tsx": {
"text-success": 2,
},
"src/components/settings/mcp-section.tsx": {
"text-success": 5,
"toast.success": 2,
},
"src/components/settings/modules-section.tsx": { "toast.success": 1 },
"src/components/settings/notification-status-card.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
"src/components/settings/security-sessions-card.tsx": { "text-success": 1 },
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
"src/components/settings/sources-section.tsx": { "toast.success": 2 },
"src/components/settings/telegram-card.tsx": { "text-success": 1 },
"src/components/settings/test-connection-button.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/settings/thresholds-editor-section.tsx": {
"toast.success": 2,
},
"src/components/settings/trusted-devices-card.tsx": { "text-success": 1 },
"src/components/settings/web-push-card.tsx": { "text-success": 1 },
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
"src/components/ui/password-strength.tsx": { "text-success": 2 },
};
/**
* Every surface that reports the result of a write, and the shared module it
* must render that result through. `forbidsMarkers` is set where the surface
* carries no legitimate success affordance of its own, so any marker in the
* file is by definition a hand-rolled verdict.
*/
const RESULT_SURFACES: ReadonlyArray<{
path: string;
through: RegExp;
forbidsMarkers: boolean;
}> = [
// The import panel (#640) and the medication intake import (#650).
{
path: "src/components/settings/import-panel/csv-import-card.tsx",
through: /CsvImportResultView|WrittenOutcomeLine/,
forbidsMarkers: true,
},
{
path: "src/components/settings/import-panel/json-import-card.tsx",
through: /CsvImportResultView|WrittenOutcomeLine/,
forbidsMarkers: true,
},
{
path: "src/components/settings/import-panel/apple-health-import-card.tsx",
through: /CsvImportResultView|WrittenOutcomeLine/,
forbidsMarkers: true,
},
{
path: "src/components/settings/import-panel/import-result-view.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: true,
},
{
path: "src/components/medications/intake-import-result.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: true,
},
{
path: "src/components/medications/intake-import-dialog.tsx",
through: /IntakeImportResultView/,
forbidsMarkers: true,
},
{
path: "src/components/settings/import-panel/dose-history-import-card.tsx",
through: /IntakeImportResultView/,
forbidsMarkers: true,
},
{
path: "src/components/settings/import-panel/dose-history-verdict.tsx",
through: /IntakeImportSkipGroups/,
forbidsMarkers: true,
},
// The seven surfaces that were still gated on the transport.
{
path: "src/components/settings/integrations/fitbit-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/google-health-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/nightscout-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/whoop-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/withings-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/settings/integrations/oauth-provider-card.tsx",
through: /WrittenOutcomeLine/,
forbidsMarkers: false,
},
{
path: "src/components/labs/ocr-review-dialog.tsx",
through: /toastWrittenOutcome/,
forbidsMarkers: true,
},
{
path: "src/components/records/ai-profile-inclusion-manager.tsx",
through: /toastWrittenOutcome/,
forbidsMarkers: true,
},
{
path: "src/components/records/health-profile-facts-manager.tsx",
through: /toastWrittenOutcome/,
forbidsMarkers: true,
},
];
/** Code only — a comment naming a marker is prose, not an affordance. */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
function readCode(relativePath: string): string {
return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8"));
}
/** Every non-test component source file, as a repo-relative posix path. */
function componentSourceFiles(): string[] {
const found: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
walk(full);
continue;
}
if (!entry.endsWith(".ts") && !entry.endsWith(".tsx")) continue;
found.push(relative(REPO_ROOT, full).split(sep).join(posix.sep));
}
};
walk(COMPONENT_ROOT);
return found.sort();
}
function markerCounts(
relativePath: string,
): Partial<Record<SuccessMarker, number>> {
const source = readCode(relativePath);
const counts: Partial<Record<SuccessMarker, number>> = {};
for (const marker of SUCCESS_MARKERS) {
const uses = source.split(marker).length - 1;
if (uses > 0) counts[marker] = uses;
}
return counts;
}
describe("success affordance — owned by the outcome module, pinned everywhere else", () => {
it("lets no unlisted component reach for the success affordance", () => {
const unlisted = componentSourceFiles()
.filter((path) => !path.startsWith(PRESENTATION_DIR))
.filter((path) => !(path in PINNED_AFFORDANCES))
.filter((path) => Object.keys(markerCounts(path)).length > 0);
expect(
unlisted,
unlisted.length > 0
? `File(s) hand-rolling a success affordance. Render the result through ` +
`WrittenOutcomeLine / toastWrittenOutcome, or add a pinned entry ` +
`here with a reason in the commit message: ${unlisted.join(", ")}`
: undefined,
).toEqual([]);
});
it("holds every listed file to the exact count it was pinned at", () => {
const drifted: string[] = [];
for (const [path, pinned] of Object.entries(PINNED_AFFORDANCES)) {
if (!existsSync(join(REPO_ROOT, path))) continue;
const actual = markerCounts(path);
for (const marker of SUCCESS_MARKERS) {
const was = pinned[marker] ?? 0;
const now = actual[marker] ?? 0;
if (was !== now) {
drifted.push(`${path}: ${marker} pinned at ${was}, found ${now}`);
}
}
}
expect(
drifted,
drifted.length > 0
? `A pinned file changed how often it reaches for the success ` +
`affordance. An ADDED one is the defect this guard exists for; a ` +
`REMOVED one is good news, update the count.\n${drifted.join("\n")}`
: undefined,
).toEqual([]);
});
it("carries no stale entry — the guard-of-the-guard", () => {
const stale = Object.keys(PINNED_AFFORDANCES).filter((path) => {
if (!existsSync(join(REPO_ROOT, path))) return true;
return Object.keys(markerCounts(path)).length === 0;
});
expect(
stale,
stale.length > 0
? `Pinned entr(ies) that no longer carry any marker (or no longer ` +
`exist). Delete them. If EVERY entry is listed here, the scanner ` +
`stopped matching and the guard is inert: ${stale.join(", ")}`
: undefined,
).toEqual([]);
});
it("routes every write-reporting surface through the shared outcome module", () => {
for (const surface of RESULT_SURFACES) {
expect(
readCode(surface.path),
`${surface.path} must render its outcome through the shared module`,
).toMatch(surface.through);
}
});
it("keeps the affordance out of the surfaces that own none of it", () => {
const offenders = RESULT_SURFACES.filter(
(surface) =>
surface.forbidsMarkers &&
Object.keys(markerCounts(surface.path)).length > 0,
).map((surface) => surface.path);
expect(
offenders,
offenders.length > 0
? `Surface(s) hand-rolling the success affordance instead of ` +
`classifying what was written: ${offenders.join(", ")}`
: undefined,
).toEqual([]);
});
});
@@ -0,0 +1,217 @@
/**
* Structural guards on workout sport provenance.
*
* Every provider labels a workout in its own vocabulary, and every ingest
* collapses that label onto the canonical `WorkoutSportType` set. The
* collapse is lossy at both ends: an unmapped label lands on `"other"`, and
* `"other"` read back cannot be told apart from a label that genuinely meant
* "other". The rule that keeps it recoverable is one line long — a
* server-side ingest that maps a raw sport label writes that label verbatim
* to `Workout.metadata`.
*
* Google Health broke that rule silently for three releases while the
* comment above its type map asserted the opposite, and a user's workout
* showed as "Other" with the original nowhere in the database. Other workout
* providers carried the provenance write, but nothing connected the paths.
*
* These are tripwires, not proofs. A grep cannot show that a metadata key
* holds the right value — only that a registered ingest still writes one, and
* that a new ingest cannot appear without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
interface ProviderIngest {
/** Human name, for the failure message. */
provider: string;
/** The exported function that resolves the upstream label. */
mapper: string;
/** File declaring the mapper. */
mapperFile: string;
/** File where the raw label is captured alongside the mapped one. */
captureFile: string;
/** Identifier or literal that carries the raw label out of the capture. */
captureField: string;
/** File that builds the `Workout` row written to Postgres. */
writeFile: string;
/** The `metadata` key the raw label is stored under. */
metadataKey: string;
}
/**
* Every server-side workout ingest that maps a raw sport label onto the
* canonical enum, with the provenance write that makes the mapping
* reversible. Adding a provider means adding a row here.
*/
const PROVIDER_INGESTS: ProviderIngest[] = [
{
provider: "Google Health",
mapper: "mapGoogleHealthSportType",
mapperFile: "lib/google-health/mappers.ts",
captureFile: "lib/google-health/mappers.ts",
captureField: "sportTypeRaw",
writeFile: "lib/google-health/sync-workout.ts",
metadataKey: "googleExerciseType",
},
{
provider: "WHOOP",
mapper: "mapWhoopSportType",
mapperFile: "lib/whoop/sport-map.ts",
captureFile: "lib/whoop/sync-workout.ts",
captureField: "sport_name",
writeFile: "lib/whoop/sync-workout.ts",
metadataKey: "whoopSportName",
},
{
provider: "Polar",
mapper: "mapPolarSportType",
mapperFile: "lib/polar/sport-map.ts",
captureFile: "lib/polar/client.ts",
captureField: "detailed_sport_info",
writeFile: "lib/polar/sync-workouts.ts",
metadataKey: "polarSport",
},
{
provider: "Strava",
mapper: "mapStravaSportType",
mapperFile: "lib/strava/sport-map.ts",
captureFile: "lib/strava/client.ts",
captureField: "rawSportType",
writeFile: "lib/strava/sync.ts",
metadataKey: "stravaType",
},
{
provider: "Apple Health export",
mapper: "resolveHkWorkoutSportType",
mapperFile: "lib/measurements/hk-workout-activity-type-map.ts",
captureFile: "lib/measurements/import-apple-health-export.ts",
captureField: "workoutActivityType",
writeFile: "lib/measurements/import-apple-health-export.ts",
metadataKey: "activityType",
},
{
provider: "Fitbit",
mapper: "mapFitbitSportType",
mapperFile: "lib/fitbit/client.ts",
captureFile: "lib/fitbit/client.ts",
captureField: "activityName",
writeFile: "lib/fitbit/sync-workout.ts",
metadataKey: "fitbitActivityName",
},
];
/**
* Exported helpers whose name ends in `SportType` but which never see an
* upstream label — they narrow a value that is already canonical, at read
* time, and have no provenance to preserve.
*/
const READ_TIME_NARROWERS = [
"closedSportType", // lib/ai/coach/workout-evidence.ts — evidence payload
"narrowSportType", // lib/workouts/insight-evidence.ts — insight claims
];
describe("T1 — the set of sport-label mappers is frozen", () => {
it("every exported sport-type resolver is registered", () => {
const found = new Set<string>();
for (const rel of sourceFiles()) {
for (const m of read(rel).matchAll(
/export function (\w*(?:SportType|WorkoutSportType))\s*\(/g,
)) {
found.add(m[1]);
}
}
const registered = [
...PROVIDER_INGESTS.map((p) => p.mapper),
...READ_TIME_NARROWERS,
].sort();
// A new name here means a new provider vocabulary, which means a new
// provenance obligation. It belongs in a reviewed diff.
expect([...found].sort()).toEqual(registered);
});
it("each registered mapper lives where the registry says it does", () => {
for (const p of PROVIDER_INGESTS) {
expect(
read(p.mapperFile).includes(`export function ${p.mapper}`),
`${p.provider}: ${p.mapper} is no longer declared in ${p.mapperFile}`,
).toBe(true);
}
});
});
describe("T2 — the set of files writing Workout rows is frozen", () => {
/**
* Non-mapping writers: they persist a `sportType` that some other layer
* already resolved, so the provenance rule does not reach them.
*/
const NON_MAPPING_WRITERS: Record<string, string> = {
// The iOS / client batch endpoint. The client sends an enum value and MAY
// send its own `metadata`; no server-side label mapping happens here.
"app/api/workouts/batch/route.ts": "client-supplied enum",
// Backup restore replays rows this app previously wrote, provenance and
// all.
"app/api/admin/backups/[id]/restore/route.ts": "restore replay",
};
it("only the registered ingests and the declared non-mappers create Workout rows", () => {
const writers = sourceFiles().filter((rel) =>
/(?:prisma|tx)\.workout\.(?:create|createMany|createManyAndReturn|upsert)\(/.test(
read(rel),
),
);
const expected = [
...PROVIDER_INGESTS.map((p) => p.writeFile),
...Object.keys(NON_MAPPING_WRITERS),
].sort();
expect(writers.sort()).toEqual(expected);
});
});
describe("T3 — every mapping ingest preserves the raw label", () => {
it.each(PROVIDER_INGESTS)(
"$provider writes metadata.$metadataKey",
({ provider, captureFile, captureField, writeFile, metadataKey }) => {
expect(
read(captureFile).includes(captureField),
`${provider}: the raw label (${captureField}) is no longer read in ${captureFile}`,
).toBe(true);
const writer = read(writeFile);
expect(
/metadata[,:]/.test(writer),
`${provider}: ${writeFile} writes no metadata onto the Workout row`,
).toBe(true);
// The key itself is declared where the metadata object is built, which
// for two providers is the capture file rather than the writer.
expect(
writer.includes(metadataKey) || read(captureFile).includes(metadataKey),
`${provider}: nothing writes the raw label under metadata.${metadataKey}`,
).toBe(true);
},
);
});
File diff suppressed because it is too large. Load diff
@@ -32,6 +32,16 @@ describe("dashboard hero — page wiring", () => {
expect(stripIdx).toBeGreaterThan(heroIdx);
});
it("asks an empty filtered digest to render its all-clear state", () => {
expect(src).toContain("renderFilteredHeroAllClear");
expect(src).toContain(
"renderFilteredAllClear={renderFilteredHeroAllClear}",
);
expect(src).toContain(
"(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS).length",
);
});
it("no longer references the retired legacy hero", () => {
expect(src).not.toContain("DashboardHero");
expect(src).not.toContain("DashboardHeroSkeleton");
+1 -1
View File
@@ -85,7 +85,7 @@ describe("v1.4.20 B3 — /api/analytics surfaces correlations", () => {
it("returns a `correlations` block on the success payload", () => {
const src = load(ANALYTICS_ROUTE_PATH);
expect(src).toMatch(/correlations,/);
expect(src).toMatch(/correlations:\s*correlationsWithPatterns,/);
expect(src).toMatch(/computeCorrelationHypotheses/);
});
@@ -33,6 +33,7 @@ vi.mock("@/lib/db", () => ({
dataBackup: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
toJson: <T>(value: T) => value,
}));
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") }));
@@ -0,0 +1,702 @@
/**
* Export → wire schema → restore, driven end to end.
*
* Every other test around the backup proves ONE end. `full-backup-payload.test.ts`
* proves the builder emits a key; the restore tests prove the route refuses a
* bad file. Between them sits the gap that lost three domains in a row: a key
* the builder emits, the schema drops or nobody reads, and the restore reports
* success over.
*
* So this test does not assert on the payload. It builds a real payload from a
* populated account, serialises it, parses it back through the REAL
* `parseBackupPayload`, hands it to the REAL route, and asserts on the rows the
* restore tried to write. A field that survives that survives a restore.
*
* The transaction stand-in is deliberately not inert: `cycleSymptom.findMany`
* answers from what the restore actually upserted. If the account's own symptom
* definitions do not ride the file, the link resolution finds nothing and the
* restore throws — which is what a real instance does, and is exactly how the
* inert v1.33.1 fix behaved in production.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { Buffer } from "node:buffer";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
decrypt: vi.fn(),
transaction: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
HttpError: class extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
$transaction: mocks.transaction,
},
toJson: <T>(value: T) => value,
}));
// Only `decrypt` is stubbed — it stands in for reading the stored backup blob.
// The rest of the module stays real, so the ciphertext this test builds and the
// ciphertext the restore writes are produced by the same code an instance runs.
vi.mock("@/lib/crypto", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/crypto")>()),
decrypt: mocks.decrypt,
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (fn: unknown) => fn,
defaultUserIdResolver: vi.fn(),
}));
vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserData: vi.fn() }));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeUserMoodRollups: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeUserMedicationCompliance: vi.fn(),
MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30,
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeUserRollups: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
const OWNER = "user-A";
/**
* A day's cumulative curve, twenty-four running totals. Uneven on purpose: a
* flat ramp would survive a restore that rebuilt the shape from `dayTotal`
* instead of carrying it, and this file is the only copy of the real one.
*/
const DAY_CURVE = [
0, 0, 0, 0, 0, 0, 120, 940, 2310, 2380, 2400, 2860, 4120, 4180, 4200, 4260,
5010, 6340, 7480, 8020, 8090, 8110, 8110, 8110,
];
/* ── the account being backed up ────────────────────────────────────── */
function sourceClient() {
const empty = { findMany: vi.fn().mockResolvedValue([]) };
return {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
measurement: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: empty,
moodEntry: empty,
moodTag: empty,
nutrientIntakeDay: empty,
// Cycle: one day-log whose symptom is one the ACCOUNT defined, which is
// the case the payload assembly used to drop.
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "day-1",
date: "2026-07-19",
cycleId: null,
flow: null,
intermenstrualBleeding: false,
basalBodyTempC: null,
temperatureExcluded: false,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: null,
notesEncrypted: null,
source: "MANUAL",
externalId: null,
tz: null,
syncVersion: 0,
deletedAt: null,
createdAt: new Date("2026-07-19T00:00:00.000Z"),
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
symptomLinks: [{ symptom: { key: "custom:jaw_tension" } }],
},
]),
},
cycleSymptom: {
findMany: vi.fn().mockResolvedValue([
{
id: "sym-1",
key: "custom:jaw_tension",
labelKey: "custom",
categoryId: "cat-1",
icon: null,
sortOrder: 0,
isActive: true,
labelEncrypted: "envelope",
},
]),
},
// Records section: empty, exercised by its own suite.
labResult: empty,
biomarker: empty,
illnessEpisode: empty,
allergy: empty,
familyHistoryEntry: empty,
workout: {
findMany: vi.fn().mockResolvedValue([
{
id: "workout-fitbit-1",
userId: OWNER,
sportType: "other",
startedAt: new Date("2026-07-18T08:00:00.000Z"),
endedAt: new Date("2026-07-18T08:30:00.000Z"),
durationSec: 1800,
totalEnergyKcal: 180,
totalDistanceM: null,
avgHeartRate: 125,
maxHeartRate: null,
minHeartRate: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
source: "FITBIT",
externalId: "91002",
metadata: {
fitbitActivityName: "Activity Zeta",
fitbitActivityTypeId: 9002,
},
createdAt: new Date("2026-07-18T08:30:00.000Z"),
updatedAt: new Date("2026-07-18T08:30:00.000Z"),
},
]),
},
inboundDocument: empty,
// The two domains this stream adds.
userHealthProfile: {
findUnique: vi.fn().mockResolvedValue({
id: "profile-1",
userId: OWNER,
aboutMeEncrypted: encryptToBytes("desk job, two kids"),
conditionsEncrypted: null,
allergiesEncrypted: encryptToBytes("penicillin"),
coachFocusEncrypted: null,
pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'),
aiIncludedSections: ["CONDITIONS", "SMOKING_STATUS"],
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
}),
},
healthProfileFactRevision: {
findMany: vi.fn().mockResolvedValue([
{
id: "fact-old",
userId: OWNER,
kind: "SMOKING_STATUS",
valueEncrypted: encryptToBytes("CURRENT"),
validFrom: new Date("2026-07-01T00:00:00.000Z"),
validUntil: new Date("2026-07-10T00:00:00.000Z"),
provenance: "USER_REPORTED",
supersededByRevisionId: "fact-current",
createdAt: new Date("2026-07-01T00:00:00.000Z"),
},
{
id: "fact-current",
userId: OWNER,
kind: "SMOKING_STATUS",
valueEncrypted: encryptToBytes("FORMER"),
validFrom: new Date("2026-07-10T00:00:00.000Z"),
validUntil: null,
provenance: "USER_CORRECTION",
supersededByRevisionId: null,
createdAt: new Date("2026-07-10T00:00:00.000Z"),
},
]),
},
// The hourly shape of a drained day. The per-sample rows it was folded
// from are already gone, so nothing but this row can rebuild it.
intradayCumulativeProfile: {
findMany: vi.fn().mockResolvedValue([
{
id: "shape-1",
userId: OWNER,
type: "ACTIVITY_STEPS",
dateKey: "2026-07-18",
hourlyCumulative: DAY_CURVE,
dayTotal: DAY_CURVE[23],
sampleCount: 96,
timezone: "Europe/Berlin",
createdAt: new Date("2026-07-19T02:00:00.000Z"),
updatedAt: new Date("2026-07-19T02:00:00.000Z"),
},
]),
},
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
id: "metric-1",
userId: OWNER,
name: "Morning grip strength",
unit: "kg",
targetLow: 40,
targetHigh: null,
decimals: 1,
description: "Right hand, before breakfast",
correlationEnabled: true,
createdAt: new Date("2026-07-01T00:00:00.000Z"),
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
deletedAt: null,
entries: [
{
id: "entry-1",
value: 44.5,
unit: "kg",
measuredAt: new Date("2026-07-19T06:30:00.000Z"),
note: "felt strong",
createdAt: new Date("2026-07-19T06:31:00.000Z"),
},
],
},
]),
},
correlationPattern: {
findMany: vi.fn().mockResolvedValue([
{
id: "pattern-1",
userId: OWNER,
canonicalKey: `p1:${"a".repeat(64)}`,
family: "DISCOVERY_RETROSPECTIVE",
factorKey: "CUSTOM_METRIC:metric-1",
outcomeKey: "RESTING_HEART_RATE",
lagDays: 1,
sampleSize: 42,
effectSize: 0.34,
pValue: 0.01,
qValue: 0.04,
evidenceHash: "b".repeat(64),
isCurrent: true,
lastComputedAt: new Date("2026-07-19T08:00:00.000Z"),
dismissedAt: new Date("2026-07-19T09:00:00.000Z"),
dismissedEvidenceHash: "b".repeat(64),
dismissedEffectSize: 0.34,
dismissedSampleSize: 42,
createdAt: new Date("2026-07-19T08:00:00.000Z"),
updatedAt: new Date("2026-07-19T09:00:00.000Z"),
},
]),
},
};
}
/* ── the instance being restored into ───────────────────────────────── */
interface Written {
model: string;
op: string;
data: Record<string, unknown>;
}
/**
* `seed` lets a test answer `findUnique`/`findMany` reads with the live
* pre-restore state (the row set the restore's cache-invalidation check
* reads before it deletes and rebuilds), keyed by model name. Every other
* test leaves it empty, which reproduces the old "nothing lives here yet"
* behaviour exactly.
*/
function recordingTx(
written: Written[],
seed: Record<string, { findUnique?: unknown; findMany?: unknown[] }> = {},
) {
// Symptom definitions the restore has written back, so link resolution can
// only succeed if the definitions actually rode the file.
const restoredSymptoms = new Map<string, string>();
const delegate = (model: string) =>
new Proxy(
{},
{
get(_t, op: string) {
return async (args: Record<string, unknown> = {}) => {
if (op === "deleteMany") return { count: 0 };
if (op === "findMany") {
if (model === "cycleSymptom") {
return [...restoredSymptoms].map(([key, id]) => ({ id, key }));
}
return seed[model]?.findMany ?? [];
}
if (op === "findFirst" || op === "findUnique") {
return seed[model]?.findUnique ?? null;
}
const data = (args.data ?? args.create ?? {}) as Record<
string,
unknown
>;
if (
op === "create" ||
op === "upsert" ||
op === "createMany" ||
op === "update"
) {
if (model === "cycleSymptom" && typeof data.key === "string") {
restoredSymptoms.set(data.key, (data.id as string) ?? "new-id");
}
written.push({ model, op, data });
}
return { id: (data.id as string) ?? `${model}-created` };
};
},
},
);
return new Proxy({} as Record<string, unknown>, {
get: (_t, model: string) => delegate(model),
});
}
function request(): NextRequest {
return new NextRequest("http://localhost/api/admin/backups/b-1/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
});
}
/**
* Build a real backup for the account above, then restore it.
*
* `payloadOverride` lets a test build the payload once, derive a "live"
* seed from its exact bytes, and restore that SAME payload — rebuilding it
* would re-encrypt every ciphertext field with a fresh random IV, making an
* unchanged-scope restore look changed for reasons that have nothing to do
* with scope.
*/
async function roundTrip(
seed: Record<string, { findUnique?: unknown; findMany?: unknown[] }> = {},
payloadOverride?: Record<string, unknown>,
): Promise<{
res: Response;
written: Written[];
payload: Record<string, unknown>;
}> {
const payload =
payloadOverride ??
(
await buildFullBackupPayload(sourceClient() as never, OWNER, {
purpose: "disaster-recovery",
})
).payload;
// The file as it lands on disk: JSON, nothing else.
mocks.decrypt.mockReturnValue(JSON.stringify(payload));
const written: Written[] = [];
mocks.transaction.mockImplementation(
async (fn: (tx: unknown) => Promise<unknown>) =>
fn(recordingTx(written, seed)),
);
const res = await (
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
)(request(), { params: Promise.resolve({ id: "b-1" }) });
return { res, written, payload };
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({
id: "b-1",
userId: OWNER,
data: "cipher",
user: { id: OWNER, username: "owner" },
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: OWNER,
username: "owner",
timezone: "UTC",
} as never);
});
describe("backup round trip — export, wire schema, restore", () => {
it("completes without the restore reporting a failure", async () => {
const { res } = await roundTrip();
expect(res.status).toBe(200);
});
it("writes the durable self-context back with its ciphertext intact", async () => {
const { written } = await roundTrip();
const profile = written.find((w) => w.model === "userHealthProfile");
expect(
profile,
"the profile reached the file and nothing wrote it back",
).toBeDefined();
expect(profile!.data.userId).toBe(OWNER);
// The envelope round-trips as bytes, not as a re-encryption of plaintext
// the DR file never carried.
expect(profile!.data.aboutMeEncrypted).toBeInstanceOf(Uint8Array);
expect(profile!.data.conditionsEncrypted).toBeNull();
expect(profile!.data.pendingQuestionsEncrypted).toBeInstanceOf(Uint8Array);
expect(profile!.data.aiIncludedSections).toEqual([
"CONDITIONS",
"SMOKING_STATUS",
]);
});
it("writes every encrypted fact revision back", async () => {
const { written } = await roundTrip();
const facts = written.filter(
(write) =>
write.model === "healthProfileFactRevision" && write.op === "create",
);
expect(facts).toHaveLength(2);
expect(facts.map((write) => write.data.id)).toEqual([
"fact-old",
"fact-current",
]);
expect(facts[0].data.valueEncrypted).toBeInstanceOf(Uint8Array);
expect(facts[1].data.valueEncrypted).toBeInstanceOf(Uint8Array);
expect(facts[0].data.validUntil).toEqual(
new Date("2026-07-10T00:00:00.000Z"),
);
expect(facts[1].data.validUntil).toBeNull();
});
it("writes each custom metric back with its readings", async () => {
const { written } = await roundTrip();
const metric = written.find((w) => w.model === "customMetric");
expect(
metric,
"the metric reached the file and nothing wrote it back",
).toBeDefined();
expect(metric!.data).toMatchObject({
userId: OWNER,
name: "Morning grip strength",
unit: "kg",
targetLow: 40,
decimals: 1,
description: "Right hand, before breakfast",
correlationEnabled: true,
});
// The readings are the half that would go missing quietly: a metric with
// no entries renders as an empty chart rather than as an error.
const entries = (
metric!.data.entries as { create: Array<Record<string, unknown>> }
).create;
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
value: 44.5,
unit: "kg",
note: "felt strong",
});
});
it("writes persisted pattern identity and dismissal evidence back", async () => {
const { written } = await roundTrip();
const pattern = written.find((w) => w.model === "correlationPattern");
expect(pattern).toBeDefined();
expect(pattern!.data).toMatchObject({
userId: OWNER,
canonicalKey: `p1:${"a".repeat(64)}`,
family: "DISCOVERY_RETROSPECTIVE",
factorKey: "CUSTOM_METRIC:metric-1",
outcomeKey: "RESTING_HEART_RATE",
dismissedEvidenceHash: "b".repeat(64),
dismissedEffectSize: 0.34,
dismissedSampleSize: 42,
});
expect(pattern!.data.dismissedAt).toEqual(
new Date("2026-07-19T09:00:00.000Z"),
);
});
it("writes unknown Fitbit workout provenance back with the workout", async () => {
const { written } = await roundTrip();
const write = written.find(
(entry) => entry.model === "workout" && entry.op === "createMany",
);
const rows = write!.data as unknown as Array<Record<string, unknown>>;
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
source: "FITBIT",
sportType: "other",
externalId: "91002",
metadata: {
fitbitActivityName: "Activity Zeta",
fitbitActivityTypeId: 9002,
},
});
});
it("writes the day's cumulative shape back, every hour of it", async () => {
const { written } = await roundTrip();
const write = written.find(
(w) => w.model === "intradayCumulativeProfile" && w.op === "createMany",
);
expect(
write,
"the day curve reached the file and nothing wrote it back",
).toBeDefined();
const rows = write!.data as unknown as Array<Record<string, unknown>>;
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
id: "shape-1",
userId: OWNER,
type: "ACTIVITY_STEPS",
dateKey: "2026-07-18",
dayTotal: DAY_CURVE[23],
sampleCount: 96,
// Dropping the zone would make a curve cut on another clock look
// comparable to one cut on this account's.
timezone: "Europe/Berlin",
});
// The curve is the part that cannot be rebuilt from anything else, so it
// is asserted hour by hour rather than by its total.
expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE);
});
it("writes the account's own cycle symptom back before resolving its links", async () => {
const { written, res } = await roundTrip();
// The definition has to land, or the link lookup below finds nothing.
expect(
written.find(
(w) =>
w.model === "cycleSymptom" && w.data.key === "custom:jaw_tension",
),
"the custom symptom definition never reached the restore",
).toBeDefined();
// And the day-log that references it restores rather than throwing on an
// unresolvable key — the failure a real account hit while the fix was inert.
expect(res.status).toBe(200);
expect(written.find((w) => w.model === "cycleDayLog")).toBeDefined();
});
describe("cached briefing invalidation on AI-scope change", () => {
// The source account's backup carries `aiIncludedSections: ["CONDITIONS",
// "SMOKING_STATUS"]` and the two SMOKING_STATUS fact revisions from
// `sourceClient()`. Reused below to build a byte-identical "live" seed so
// the unchanged-scope case is a genuine no-op, not a coincidence of two
// separately encrypted ciphertexts happening to differ.
function liveFactRowsFromPayload(payload: Record<string, unknown>) {
const facts = payload.healthProfileFacts as Array<{
id: string;
kind: string;
valueEncrypted: string;
validFrom: string;
validUntil: string | null;
provenance: string;
supersededByRevisionId: string | null;
}>;
return facts.map((fact) => ({
id: fact.id,
userId: OWNER,
kind: fact.kind,
valueEncrypted: Buffer.from(fact.valueEncrypted, "base64"),
validFrom: new Date(fact.validFrom),
validUntil: fact.validUntil ? new Date(fact.validUntil) : null,
provenance: fact.provenance,
supersededByRevisionId: fact.supersededByRevisionId,
}));
}
it("clears the cached briefing when the restored scope narrows", async () => {
// Live account currently has every section included — strictly wider
// than the backup's ["CONDITIONS", "SMOKING_STATUS"], so the restore
// narrows scope and must invalidate the pre-restore cache.
const { written } = await roundTrip({
userHealthProfile: {
findUnique: {
aiIncludedSections: [
"ABOUT_ME",
"CONDITIONS",
"ALLERGIES",
"COACH_FOCUS",
"FAMILY_HISTORY",
"SMOKING_STATUS",
"ALCOHOL_PATTERN",
"SHIFT_SCHEDULE",
],
},
},
});
const userUpdate = written.find(
(w) => w.model === "user" && w.op === "update",
);
expect(
userUpdate,
"a scope-narrowing restore must clear the cached briefing",
).toBeDefined();
expect(userUpdate!.data).toEqual({
insightsCachedText: null,
insightsCachedAt: null,
});
});
it("leaves the cached briefing alone when the restored scope is unchanged", async () => {
const { payload } = await buildFullBackupPayload(
sourceClient() as never,
OWNER,
{ purpose: "disaster-recovery" },
);
const liveSeed = {
userHealthProfile: {
findUnique: {
aiIncludedSections: (
payload.healthProfile as { aiIncludedSections: string[] }
).aiIncludedSections,
},
},
healthProfileFactRevision: {
findMany: liveFactRowsFromPayload(payload),
},
};
// Restore that SAME payload — rebuilding it would re-encrypt every
// ciphertext field with a fresh IV, so the only variable this way is
// whether the restore over-invalidates on a genuine no-op.
const { written: writtenUnchanged } = await roundTrip(liveSeed, payload);
const userUpdate = writtenUnchanged.find(
(w) => w.model === "user" && w.op === "update",
);
expect(
userUpdate,
"an unchanged AI scope must not clear the cached briefing",
).toBeUndefined();
});
});
});
@@ -14,7 +14,7 @@
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { prisma, toJson } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
@@ -38,6 +38,8 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -64,6 +66,11 @@ interface RestoreResponse {
familyHistory: number;
workouts: number;
documents: number;
healthProfile: number;
healthProfileFactRevisions: number;
customMetrics: number;
correlationPatterns: number;
intradayProfiles: number;
};
}
@@ -562,6 +569,32 @@ const handler = apiHandler(
}
if (payload.intakeEvents.length > 0) {
// An event whose medication did not come back used to be mapped to
// null and filtered away: the dose history came back short and the
// restore reported success. That is the same silent-drop shape the
// cycle symptom links had, and it gets the same answer — name the
// unresolved reference and stop, so the operator learns from the
// error rather than from a compliance rate that quietly moved.
const unresolvedMedications = [
...new Set(
payload.intakeEvents
.filter(
(e) =>
!(
e.medicationId &&
restoredMedicationIds.has(e.medicationId)
) && !medByName.has(e.medication),
)
.map((e) => e.medicationId ?? e.medication),
),
];
if (unresolvedMedications.length > 0) {
throw new Error(
`Unknown medication references in intake events: ${unresolvedMedications.join(
", ",
)}. The backup records doses against medications it does not carry.`,
);
}
const rows = payload.intakeEvents
.map((e) => {
const medId =
@@ -718,6 +751,21 @@ const handler = apiHandler(
// above. `notesEncrypted` is restored as ciphertext verbatim.
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
// Durable self-context + user-defined metrics. Both ends of this pair
// live in `src/lib/export/profile-backup.ts` beside its builder — a
// grep of THIS file alone will not find them, exactly as it does not
// find the cycle restore one line above.
const profileCleared = await restoreProfileData(tx, ownerId, payload);
// The hourly shape of a cumulative day. Both ends live in
// `src/lib/export/intraday-profile-backup.ts` beside its builder, so
// a grep of THIS file alone will not find them either.
const intradayCleared = await restoreIntradayProfileData(
tx,
ownerId,
payload,
);
const biomarkerByName = new Map<string, string>();
const restoredBiomarkerIds = new Set<string>();
for (const biomarker of payload.biomarkers) {
@@ -994,6 +1042,9 @@ const handler = apiHandler(
pauseDurationSec: workout.pauseDurationSec ?? null,
source: workout.source as never,
externalId: workout.externalId ?? null,
...(workout.metadata == null
? {}
: { metadata: toJson(workout.metadata) }),
...(workout.createdAt
? { createdAt: new Date(workout.createdAt) }
: {}),
@@ -1073,6 +1124,12 @@ const handler = apiHandler(
familyHistory: familyHistory.count,
workouts: workouts.count,
documents: documents.count,
healthProfile: profileCleared.healthProfile,
healthProfileFactRevisions:
profileCleared.healthProfileFactRevisions,
customMetrics: profileCleared.customMetrics,
correlationPatterns: profileCleared.correlationPatterns,
intradayProfiles: intradayCleared.intradayProfiles,
};
},
{
@@ -1197,6 +1254,11 @@ const handler = apiHandler(
familyHistory: summary.familyHistory,
workouts: summary.workouts,
documents: summary.documents,
healthProfile: summary.healthProfile,
healthProfileFactRevisions: summary.healthProfileFactRevisions,
customMetrics: summary.customMetrics,
customMetricEntries: summary.customMetricEntries,
correlationPatterns: summary.correlationPatterns,
},
},
});
@@ -44,10 +44,16 @@ vi.mock("@/lib/jobs/worker-status", () => ({
})),
}));
vi.mock("@/lib/jobs/job-failures", () => ({
JOB_FAILURE_WINDOW_HOURS: 72,
readFailingQueues: vi.fn(),
}));
// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { readFailingQueues } from "@/lib/jobs/job-failures";
const ADMIN_CTX = {
authMethod: "cookie" as const,
@@ -69,6 +75,7 @@ beforeEach(() => {
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
vi.mocked(prisma.session.count).mockResolvedValue(0);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
vi.mocked(readFailingQueues).mockResolvedValue([]);
});
describe("GET /api/admin/status (integration health summary)", () => {
@@ -148,7 +155,7 @@ describe("GET /api/admin/status (integration health summary)", () => {
const body = (await res.json()) as {
data: {
counts: Record<string, number>;
worker: { running: boolean };
worker: { running: boolean; errors?: number };
database: string;
};
};
@@ -161,10 +168,44 @@ describe("GET /api/admin/status (integration health summary)", () => {
activeSessions: 0,
});
expect(body.data.worker.running).toBe(true);
expect(body.data.worker).not.toHaveProperty("errors");
expect(body.data.database).toBe("connected");
// Regression: only NON-revoked tokens are counted as "active".
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
});
it("reports named failing queues through failingJobs only", async () => {
vi.mocked(readFailingQueues).mockResolvedValueOnce([
{
queue: "environment-fetch",
failures: 2,
lastFailedAt: "2026-07-28T12:00:00.000Z",
lastError: "upstream unavailable",
},
]);
const res = await GET();
const body = (await res.json()) as {
data: {
worker: Record<string, unknown>;
failingJobs: {
windowHours: number;
queues: Array<{ queue: string; failures: number }>;
};
};
};
expect(body.data.worker).not.toHaveProperty("errors");
expect(body.data.failingJobs).toEqual({
windowHours: 72,
queues: [
expect.objectContaining({
queue: "environment-fetch",
failures: 2,
}),
],
});
});
});
+9 -1
View File
@@ -75,7 +75,15 @@ export const GET = apiHandler(async () => {
buildTime: BUILD_TIME,
startTime: START_TIME,
database: "connected",
worker: workerStatus,
worker: {
running: workerStatus.running,
startedAt: workerStatus.startedAt,
lastHeartbeat: workerStatus.lastHeartbeat,
lastReminderCheck: workerStatus.lastReminderCheck,
lastWithingsSync: workerStatus.lastWithingsSync,
lastInsightsRun: workerStatus.lastInsightsRun,
jobsProcessed: workerStatus.jobsProcessed,
},
failingJobs:
failingJobs === null
? null
+25 -2
View File
@@ -28,6 +28,13 @@ vi.mock("@/lib/db", () => ({
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
// v1.34 — the versioned reference score reads these three models
// directly (not through the shared measurement/rollup path); default
// to empty/null so a brand-new-user response doesn't throw before the
// route can even decide the composite is ungraded.
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
labResult: { findMany: vi.fn().mockResolvedValue([]) },
dismissedPriorityItem: { findUnique: vi.fn().mockResolvedValue(null) },
// v1.4.33 C1 — slim summaries slice runs through `$queryRaw`. The
// v1.4.36 per-type coverage probe and the v1.4.37 default-slice
// probe also ride `$queryRaw`. Default to an empty coverage map so
@@ -69,6 +76,12 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/insights/correlation-patterns", () => ({
PATTERN_FAMILIES: { fixed: "FIXED" },
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
decisionForEvidence: vi.fn().mockReturnValue(null),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -133,6 +146,13 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.dismissedPriorityItem.findUnique).mockResolvedValue(
null as never,
);
// v1.4.35 — rollup table defaults. `resetAllMocks` clears the
// module-level implementations, so we re-seed both per test. Empty
// findMany + null findFirst means the slim slice's parity check
@@ -204,13 +224,16 @@ describe("GET /api/analytics", () => {
data: {
summaries: Record<string, { count: number }>;
bmi: number | null;
healthScore: unknown;
healthScore: { composite: { status: string } } | null;
};
};
expect(body.data.summaries.PULSE.count).toBe(0);
expect(body.data.summaries.WEIGHT.count).toBe(0);
expect(body.data.bmi).toBeNull();
expect(body.data.healthScore).toBeNull();
// v1.34 — the versioned reference score always returns a structured
// report, even with zero data; a brand-new user reads as an explicit
// "insufficient" composite, never a bare null.
expect(body.data.healthScore?.composite.status).toBe("insufficient");
// v1.4.49.1 — the legacy 15-way per-type live walk used a chunked
// pagination select `(id, measuredAt, value, source, deviceType)`
// unique to `fetchMeasurementSeriesChunked`. Other `findMany` calls
+169 -73
View File
@@ -6,7 +6,8 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
@@ -16,10 +17,9 @@ import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
import { isModuleEnabled } from "@/lib/modules/gate";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -29,6 +29,11 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import {
decisionForEvidence,
PATTERN_FAMILIES,
syncAcceptedPatterns,
} from "@/lib/insights/correlation-patterns";
export const dynamic = "force-dynamic";
@@ -289,7 +294,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// these envelopes inside `buildHealthScoreBpInputs`.
let bpEnvelope: BpInTargetEnvelope | null = null;
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
const bpTargets = resolveEffectiveBpTargets(
{
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
},
user.thresholdsJson,
);
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
// v1.4.37 W2 — probe-gated dispatcher replaces the inline chunked
@@ -312,29 +326,67 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// pair of `readRollupBuckets` reads on the rollup path and is a
// no-op when the user has no BP rows at all.
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek] = await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
// to the live SQL path when the user is more than 3 hours
// from UTC, where the rollup table's UTC-midnight day-key
// would slip a calendar day relative to the live aggregator's
// window cuts.
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
]);
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
[
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
// to the live SQL path when the user is more than 3 hours
// from UTC, where the rollup table's UTC-midnight day-key
// would slip a calendar day relative to the live aggregator's
// window cuts.
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
],
);
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
} else if (clinicalBpTargets) {
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: clinicalBpTargets,
now: fourteenDaysAgo,
coverage,
userTz,
}),
]);
}
// v1.17 W1d — the BD-Zielbereich headline standardises on the
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
// remains carried below for the BP detail page's long view only.
@@ -415,51 +467,100 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
coverage,
locale,
});
const fixedDefinitions = [
{
key: "bpCompliance" as const,
factorKey: "HYPOTHESIS:MEDICATION_COMPLIANCE",
outcomeKey: "BLOOD_PRESSURE_SYS",
},
{
key: "moodPulse" as const,
factorKey: "HYPOTHESIS:MOOD",
outcomeKey: "RESTING_PULSE",
},
{
key: "weightWeekday" as const,
factorKey: "HYPOTHESIS:WEEKDAY",
outcomeKey: "WEIGHT",
},
];
const acceptedFixed = fixedDefinitions.flatMap((definition) => {
const result = correlations[definition.key];
return result.status === "ok"
? [
{
factorKey: definition.factorKey,
outcomeKey: definition.outcomeKey,
lagDays: 0,
sampleSize: result.n,
effectSize: result.statistic,
pValue: result.pValue,
qValue: null,
},
]
: [];
});
const fixedDecisions = await syncAcceptedPatterns({
userId: user.id,
family: PATTERN_FAMILIES.fixed,
accepted: acceptedFixed,
});
const correlationsWithPatterns = { ...correlations };
for (const definition of fixedDefinitions) {
const result = correlations[definition.key];
if (result.status !== "ok") continue;
const decision = decisionForEvidence(fixedDecisions, {
factorKey: definition.factorKey,
outcomeKey: definition.outcomeKey,
lagDays: 0,
});
correlationsWithPatterns[definition.key] = decision
? { ...result, ...decision }
: result;
}
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
// composite of BP-in-target % + weight-trend alignment + mood
// stability + medication compliance. The "vs last week" delta
// re-runs the same compute against a 7-day-shifted snapshot.
//
// v1.4.37 W2 — probe-gated helper. The weight pillar derives from
// DAY-bucket means on `measurement_rollups` when the user has full
// coverage; the source-attribution accordion still pulls a narrow
// 2-column projection from `measurements` for the ingest-path
// pills. Falls back to the legacy 37-day raw read on partial /
// missing coverage. Path annotate sits on `meta.healthScore.path`.
//
// v1.17 W1b — the BP-pillar inputs come from the ONE shared
// `buildHealthScoreBpInputs` builder the dashboard snapshot also uses, so
// the ring and the insights card grade the pillar off identical inputs
// (same 90-day window via W1d, same all-time fallback, same graded score,
// same prior-week delta values). The hand-rolled per-surface assembly that
// let the two diverge is gone.
const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek);
// v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood
// account drops the mood-stability pillar from the ring instead of
// being silently scored on data hidden everywhere else. The dashboard
// already blanks the mood tile + the coach snapshot unions mood out;
// this closes the matching gap on the Health Score.
const moodEnabled = await isModuleEnabled(user.id, "mood");
// v1.18.0 B1 — resolve the `glucose` module server-side. The analytics
// route serves core metrics too, so a disabled-glucose account must NOT
// 403 the whole payload; instead the glucose-only surfaces (per-context
// summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they
// never reach a hidden surface. Mirrors the mood-pillar gate above.
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
// v1.34 — the versioned cardiometabolic reference report. The shared reader
// admits only evidence-backed pillars with their own windows and coverage
// floors, then applies the three-domain breadth rule. Its BP envelopes come
// from the same rollup/live fast path the dashboard uses; every other domain
// resolves through the canonical reader and fails independently.
const [
glucoseEnabled,
labsEnabled,
sleepEnabled,
mentalHealthEnabled,
scoreSourcePriority,
] = await Promise.all([
isModuleEnabled(user.id, "glucose"),
isModuleEnabled(user.id, "labs"),
isModuleEnabled(user.id, "sleep"),
isModuleEnabled(user.id, "mentalHealth"),
loadUserSourcePriority(user.id),
]);
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
// The reference instant the score is graded as-of. Reused below so the Rest
// Mode annotation resolves against the SAME window the score covers, not a
// fresh "now" that could fall outside it.
const scoredAt = new Date();
const healthScore = await computeUserHealthScoreFastPath({
const healthScore = await computeUserHealthScore({
userId: user.id,
...bpInputs,
heightCm: user.heightCm ?? null,
now: scoredAt,
coverage,
moodEnabled,
profile: {
dateOfBirth: user.dateOfBirth,
gender: user.gender,
heightCm: user.heightCm ?? null,
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson ?? null,
},
modules: {
glucose: glucoseEnabled,
labs: labsEnabled,
sleep: sleepEnabled,
mentalHealth: mentalHealthEnabled,
},
bpTargets: clinicalBpTargets,
bpEnvelope,
bpEnvelopePriorWeek,
bpEnvelopePriorTwoWeeks,
});
// v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is
@@ -491,7 +592,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
bpInTargetSpanDays90,
glucoseByContext: glucoseByContextOut,
glucoseClinical: glucoseClinicalOut,
correlations,
correlations: correlationsWithPatterns,
healthScore,
sleepStages,
// v1.28.x — the latest night's source-discrepancy annotation, computed
@@ -574,6 +675,7 @@ async function computeSleepStageBreakdown(
.map((n) => {
const nightStages: Record<string, number> = {};
for (const [stage, mins] of Object.entries(n.stages)) {
if (mins == null) continue;
nightStages[stage] = mins;
stages[stage] = (stages[stage] ?? 0) + mins;
totalMinutes += mins;
@@ -613,9 +715,3 @@ async function computeSleepStageBreakdown(
// `src/lib/analytics/correlations-fast-path.ts`. They were only ever
// used by the weight-weekday + day-key helpers inside the old inline
// correlation builder.
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
// and the inline `computeUserHealthScore` body relocated to
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
// rollup / live dispatcher can be unit-tested independently of the
// route. The route now delegates through `computeUserHealthScoreFastPath`.
+130
View File
@@ -0,0 +1,130 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
correctHealthProfileFact,
removeHealthProfileFact,
} from "@/lib/profile/health-facts";
import {
healthProfileFactCorrectionSchema,
validateCorrectedFactValue,
type HealthProfileFactKind,
} from "@/lib/validations/health-profile-facts";
type RouteParams = { params: Promise<{ id: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = healthProfileFactCorrectionSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const current = await prisma.healthProfileFactRevision.findFirst({
where: {
id,
userId: user.id,
validUntil: null,
supersededByRevisionId: null,
},
select: { kind: true },
});
if (!current) return apiError("Current profile fact not found", 404);
const value = validateCorrectedFactValue(
current.kind as HealthProfileFactKind,
parsed.data.value,
);
if (!value) {
return apiError("Value is not valid for this fact kind", 422, {
errorCode: "anamnesis.fact.invalidValue",
});
}
const corrected = await correctHealthProfileFact(user.id, id, value);
if (!corrected) {
return apiError("Profile fact changed since it was loaded", 409, {
errorCode: "anamnesis.fact.conflict",
});
}
await auditLog("anamnesis.fact.correct", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
priorRevisionId: id,
revisionId: corrected.id,
kind: corrected.kind,
},
});
annotate({
action: {
name: "anamnesis.fact.correct",
entity_type: "health_profile_fact_revision",
entity_id: corrected.id,
},
meta: { kind: corrected.kind },
});
return apiSuccess(corrected);
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const current = await prisma.healthProfileFactRevision.findFirst({
where: {
id,
userId: user.id,
validUntil: null,
supersededByRevisionId: null,
},
select: { kind: true },
});
if (!current) return apiError("Current profile fact not found", 404);
const removed = await removeHealthProfileFact(user.id, id);
if (!removed) {
return apiError("Profile fact changed since it was loaded", 409, {
errorCode: "anamnesis.fact.conflict",
});
}
await auditLog("anamnesis.fact.remove", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
revisionId: removed.id,
kind: removed.kind,
},
});
annotate({
action: {
name: "anamnesis.fact.remove",
entity_type: "health_profile_fact_revision",
entity_id: removed.id,
},
meta: { kind: removed.kind },
});
return apiSuccess(removed);
},
);
export const dynamic = "force-dynamic";
@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
read: vi.fn(),
create: vi.fn(),
correct: vi.fn(),
remove: vi.fn(),
currentFindFirst: vi.fn(),
audit: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (handler: unknown) => handler,
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.audit }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
healthProfileFactRevision: { findFirst: mocks.currentFindFirst },
},
}));
vi.mock("@/lib/profile/health-facts", () => ({
readHealthProfileFacts: mocks.read,
createHealthProfileFact: mocks.create,
correctHealthProfileFact: mocks.correct,
removeHealthProfileFact: mocks.remove,
}));
import { GET, POST } from "../route";
import { DELETE, PATCH } from "../[id]/route";
function request(method: string, body?: unknown): Request {
return new Request("http://localhost/api/anamnesis/facts", {
method,
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
const dto = {
id: "fact-1",
kind: "SMOKING_STATUS",
value: "FORMER",
unreadable: false,
validFrom: "2026-07-28T10:00:00.000Z",
validUntil: null,
provenance: "USER_REPORTED",
supersededByRevisionId: null,
createdAt: "2026-07-28T10:00:00.000Z",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.read.mockResolvedValue({
current: {
SMOKING_STATUS: dto,
ALCOHOL_PATTERN: null,
SHIFT_SCHEDULE: null,
},
history: [dto],
});
mocks.create.mockResolvedValue(dto);
mocks.currentFindFirst.mockResolvedValue({ kind: "SMOKING_STATUS" });
mocks.correct.mockResolvedValue({
...dto,
id: "fact-2",
value: "NEVER",
provenance: "USER_CORRECTION",
});
mocks.remove.mockResolvedValue({
id: "fact-1",
kind: "SMOKING_STATUS",
removedAt: "2026-07-28T12:00:00.000Z",
});
});
describe("anamnesis facts API", () => {
it("returns current facts together with immutable history", async () => {
const response = await (GET as unknown as () => Promise<Response>)();
const body = await response.json();
expect(response.status).toBe(200);
expect(body.data.current.SMOKING_STATUS.value).toBe("FORMER");
expect(body.data.history).toHaveLength(1);
expect(mocks.read).toHaveBeenCalledWith("user-1");
});
it("accepts only a value from the selected closed fact kind", async () => {
const response = await (
POST as unknown as (request: Request) => Promise<Response>
)(request("POST", { kind: "SMOKING_STATUS", value: "FORMER" }));
expect(response.status).toBe(201);
expect(mocks.create).toHaveBeenCalledWith(
"user-1",
"SMOKING_STATUS",
"FORMER",
);
expect(mocks.audit).toHaveBeenCalledOnce();
});
it("rejects a value belonging to another fact kind", async () => {
const response = await (
POST as unknown as (request: Request) => Promise<Response>
)(request("POST", { kind: "SMOKING_STATUS", value: "ROTATING" }));
expect(response.status).toBe(422);
expect(mocks.create).not.toHaveBeenCalled();
});
it("corrects only the caller's current revision", async () => {
const response = await (
PATCH as unknown as (
request: Request,
context: { params: Promise<{ id: string }> },
) => Promise<Response>
)(request("PATCH", { value: "NEVER" }), {
params: Promise.resolve({ id: "fact-1" }),
});
expect(response.status).toBe(200);
expect(mocks.currentFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "fact-1", userId: "user-1" }),
}),
);
expect(mocks.correct).toHaveBeenCalledWith("user-1", "fact-1", "NEVER");
expect(mocks.audit).toHaveBeenCalledOnce();
});
it("does not expose another account's revision as existing", async () => {
mocks.currentFindFirst.mockResolvedValue(null);
const response = await (
PATCH as unknown as (
request: Request,
context: { params: Promise<{ id: string }> },
) => Promise<Response>
)(request("PATCH", { value: "NEVER" }), {
params: Promise.resolve({ id: "other-user-fact" }),
});
expect(response.status).toBe(404);
expect(mocks.correct).not.toHaveBeenCalled();
});
it("removes only the caller's current revision and audits metadata without its value", async () => {
const response = await (
DELETE as unknown as (
request: Request,
context: { params: Promise<{ id: string }> },
) => Promise<Response>
)(request("DELETE"), {
params: Promise.resolve({ id: "fact-1" }),
});
const body = await response.json();
expect(response.status).toBe(200);
expect(body.data).toEqual({
id: "fact-1",
kind: "SMOKING_STATUS",
removedAt: "2026-07-28T12:00:00.000Z",
});
expect(body.data).not.toHaveProperty("value");
expect(mocks.currentFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: "fact-1",
userId: "user-1",
validUntil: null,
supersededByRevisionId: null,
},
}),
);
expect(mocks.remove).toHaveBeenCalledWith("user-1", "fact-1");
expect(mocks.audit).toHaveBeenCalledWith("anamnesis.fact.remove", {
userId: "user-1",
ipAddress: null,
details: {
revisionId: "fact-1",
kind: "SMOKING_STATUS",
},
});
expect(JSON.stringify(mocks.audit.mock.calls[0])).not.toContain("FORMER");
});
it("does not expose a missing, closed, or other-owner removal target", async () => {
mocks.currentFindFirst.mockResolvedValue(null);
const response = await (
DELETE as unknown as (
request: Request,
context: { params: Promise<{ id: string }> },
) => Promise<Response>
)(request("DELETE"), {
params: Promise.resolve({ id: "other-user-fact" }),
});
expect(response.status).toBe(404);
expect(mocks.remove).not.toHaveBeenCalled();
expect(mocks.audit).not.toHaveBeenCalled();
});
it("returns a conflict when the revision changes after the owner-scoped read", async () => {
mocks.remove.mockResolvedValue(null);
const response = await (
DELETE as unknown as (
request: Request,
context: { params: Promise<{ id: string }> },
) => Promise<Response>
)(request("DELETE"), {
params: Promise.resolve({ id: "fact-1" }),
});
const body = await response.json();
expect(response.status).toBe(409);
expect(body.meta.errorCode).toBe("anamnesis.fact.conflict");
expect(mocks.audit).not.toHaveBeenCalled();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import {
createHealthProfileFact,
readHealthProfileFacts,
} from "@/lib/profile/health-facts";
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const payload = await readHealthProfileFacts(user.id);
annotate({
action: { name: "anamnesis.facts.get" },
meta: {
current_count: Object.values(payload.current).filter(Boolean).length,
revision_count: payload.history.length,
},
});
return apiSuccess(payload);
});
async function postFact(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: rawBody, error } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = healthProfileFactWriteSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
try {
const fact = await createHealthProfileFact(
user.id,
parsed.data.kind,
parsed.data.value,
);
await auditLog("anamnesis.fact.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { revisionId: fact.id, kind: fact.kind },
});
annotate({
action: {
name: "anamnesis.fact.create",
entity_type: "health_profile_fact_revision",
entity_id: fact.id,
},
meta: { kind: fact.kind },
});
return apiSuccess(fact, 201);
} catch (error) {
if (isP2002(error)) {
return apiError("A current value already exists for this fact", 409, {
errorCode: "anamnesis.fact.currentExists",
});
}
throw error;
}
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postFact));
export const dynamic = "force-dynamic";
+3
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
decryptContextFromBytes,
encryptContextToBytes,
@@ -169,6 +170,7 @@ export const PUT = apiHandler(
}
const updated = await prisma.biomarker.update({ where: { id }, data });
invalidateUserHealthScore(user.id);
await auditLog("biomarker.update", {
userId: user.id,
@@ -206,6 +208,7 @@ export const DELETE = apiHandler(
}),
prisma.biomarker.delete({ where: { id } }),
]);
invalidateUserHealthScore(user.id);
await auditLog("biomarker.delete", {
userId: user.id,
@@ -41,15 +41,33 @@ vi.mock("@/lib/api-response", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
const { profile } = vi.hoisted(() => ({
profile: {
const { profile, userUpdate, transaction } = vi.hoisted(() => {
const profile = {
upsert: vi.fn(),
updateMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
};
const userUpdate = vi.fn(async () => ({}));
const tx = {
userHealthProfile: profile,
user: { update: userUpdate },
};
return {
profile,
userUpdate,
transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
callback(tx),
),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
$transaction: transaction,
userHealthProfile: profile,
user: { update: userUpdate },
},
}));
vi.mock("@/lib/db", () => ({ prisma: { userHealthProfile: profile } }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
@@ -65,8 +83,9 @@ vi.mock("@/lib/ai/coach/about-me", () => ({
allergies: null,
coachFocus: null,
})),
filterSelfContextForAi: (ctx: Record<string, string | null>) => ctx,
getPendingQuestionsForUser: vi.fn(async () => []),
setPendingQuestionsForUser: vi.fn(),
setPendingQuestionsForUser: vi.fn(async () => true),
}));
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
deriveClarifyingQuestions: vi.fn(async () => ({
@@ -76,21 +95,35 @@ vi.mock("@/lib/ai/coach/self-context-questions", () => ({
}));
import { PUT } from "../route";
import {
getPendingQuestionsForUser,
getSelfContextForUser,
setPendingQuestionsForUser,
} from "@/lib/ai/coach/about-me";
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
type Envelope = {
data: { updatedAt?: string } | null;
data: {
updatedAt?: string;
pendingQuestions?: string[];
aiIncludedSections?: string[];
} | null;
error: string | null;
status: number;
meta?: { errorCode?: string };
};
const put = PUT as unknown as (req: Request) => Promise<Envelope>;
function putReq(baseUpdatedAt?: string): Request {
function putReq(
baseUpdatedAt?: string,
overrides: Record<string, unknown> = {},
): Request {
return new Request("http://localhost/api/coach/about-me", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
aboutMe: "I run daily",
...overrides,
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
}),
});
@@ -123,19 +156,54 @@ describe("about-me — optimistic concurrency", () => {
expect(res.data?.updatedAt).toBe(advanced.toISOString());
});
it("a stale base token 409s and clobbers nothing (existing row advanced)", async () => {
it("returns the post-question token so the next guarded write can use it", async () => {
const afterProfileWrite = new Date("2026-07-24T10:05:00.000Z");
const afterQuestions = new Date("2026-07-24T10:06:00.000Z");
const secondProfileWrite = new Date("2026-07-24T10:07:00.000Z");
const secondQuestions = new Date("2026-07-24T10:08:00.000Z");
profile.upsert.mockResolvedValue({ updatedAt: afterProfileWrite });
profile.updateMany.mockResolvedValue({ count: 1 });
profile.findUnique
.mockResolvedValueOnce({
updatedAt: afterProfileWrite,
aiIncludedSections: ["ABOUT_ME"],
})
.mockResolvedValueOnce({ updatedAt: afterQuestions })
.mockResolvedValueOnce({ updatedAt: secondProfileWrite })
.mockResolvedValueOnce({
updatedAt: secondProfileWrite,
aiIncludedSections: ["ABOUT_ME"],
})
.mockResolvedValueOnce({ updatedAt: secondQuestions });
const first = await put(putReq());
expect(first.data?.updatedAt).toBe(afterQuestions.toISOString());
const second = await put(putReq(first.data?.updatedAt));
expect(second.status).toBe(200);
expect(profile.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "u1", updatedAt: afterQuestions },
}),
);
expect(second.data?.updatedAt).toBe(secondQuestions.toISOString());
});
it("a stale inclusion save 409s before it can clobber newer About Me text", async () => {
profile.updateMany.mockResolvedValue({ count: 0 });
// The row exists but its token advanced → real conflict, not a delete.
profile.findUnique.mockResolvedValue({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
});
const res = await put(putReq("2026-07-24T09:00:00.000Z"));
const res = await put(
putReq("2026-07-24T09:00:00.000Z", {
aboutMe: "stale text from the inclusion manager",
aiIncludedSections: ["ABOUT_ME"],
}),
);
expect(res.status).toBe(409);
expect(res.data).toBeNull();
expect(res.meta?.errorCode).toBe("about_me_conflict");
// No write happened: neither the upsert nor a create ran.
expect(profile.upsert).not.toHaveBeenCalled();
expect(profile.create).not.toHaveBeenCalled();
});
@@ -153,6 +221,28 @@ describe("about-me — optimistic concurrency", () => {
expect(res.data?.updatedAt).toBe("2026-07-24T12:00:00.000Z");
});
it("updates inclusion controls without rewriting encrypted profile text", async () => {
profile.upsert.mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
});
const res = await put(
putReq(undefined, {
aboutMe: undefined,
aiIncludedSections: ["CONDITIONS"],
}),
);
expect(res.status).toBe(200);
const write = profile.upsert.mock.calls[0]?.[0] as {
create: Record<string, unknown>;
update: Record<string, unknown>;
};
expect(write.create).not.toHaveProperty("aboutMeEncrypted");
expect(write.update).not.toHaveProperty("aboutMeEncrypted");
expect(write.update.aiIncludedSections).toEqual(["CONDITIONS"]);
});
it("keeps the unconditional upsert when the token is omitted (iOS compat)", async () => {
profile.upsert.mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
@@ -165,6 +255,14 @@ describe("about-me — optimistic concurrency", () => {
expect(res.data?.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
it("rejects an empty update without touching the row", async () => {
const res = await put(putReq(undefined, { aboutMe: undefined }));
expect(res.status).toBe(422);
expect(profile.upsert).not.toHaveBeenCalled();
expect(profile.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await put(putReq("not-a-date"));
expect(res.status).toBe(422);
@@ -172,4 +270,82 @@ describe("about-me — optimistic concurrency", () => {
expect(profile.updateMany).not.toHaveBeenCalled();
expect(profile.upsert).not.toHaveBeenCalled();
});
it("does not let a stale derivation overwrite newer scoped questions", async () => {
const versionA = new Date("2026-07-24T10:00:00.000Z");
const versionB = new Date("2026-07-24T10:01:00.000Z");
const staleQuestions = ["What does your running routine look like?"];
const newerQuestions = ["How is asthma affecting you today?"];
let currentUpdatedAt = versionA;
let currentSections = ["ABOUT_ME"];
let storedQuestions = [] as string[];
profile.upsert.mockResolvedValue({ updatedAt: versionA });
profile.findUnique.mockImplementation(
async (args: { select: Record<string, boolean> }) =>
args.select.aiIncludedSections
? { aiIncludedSections: currentSections }
: { updatedAt: currentUpdatedAt },
);
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
aboutMe: "I run daily",
conditions: null,
allergies: null,
coachFocus: null,
});
vi.mocked(getPendingQuestionsForUser).mockImplementation(
async () => storedQuestions,
);
vi.mocked(setPendingQuestionsForUser).mockImplementation(
async (_userId, questions, expectedUpdatedAt) => {
if (
expectedUpdatedAt === undefined ||
expectedUpdatedAt.getTime() !== currentUpdatedAt.getTime()
) {
return false;
}
storedQuestions = questions ?? [];
return true;
},
);
let markDerivationStarted!: () => void;
const derivationStarted = new Promise<void>((resolve) => {
markDerivationStarted = resolve;
});
let finishDerivation!: (value: {
questions: string[];
source: "ai";
}) => void;
const derivationFinished = new Promise<{
questions: string[];
source: "ai";
}>((resolve) => {
finishDerivation = resolve;
});
vi.mocked(deriveClarifyingQuestions).mockImplementationOnce(async () => {
markDerivationStarted();
return derivationFinished;
});
const requestA = put(
putReq(undefined, { aiIncludedSections: ["ABOUT_ME"] }),
);
await derivationStarted;
currentUpdatedAt = versionB;
currentSections = ["CONDITIONS"];
storedQuestions = newerQuestions;
finishDerivation({ questions: staleQuestions, source: "ai" });
const responseA = await requestA;
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
"u1",
staleQuestions,
versionA,
);
expect(storedQuestions).toEqual(newerQuestions);
expect(getPendingQuestionsForUser).toHaveBeenCalledWith("u1");
expect(responseA.data?.pendingQuestions).toEqual(newerQuestions);
});
});
@@ -1,11 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.18.0 — the about-me self-context GET/PUT enforce the two-layer
* Coach module gate (operator availability AND the per-user
* `disableCoach` opt-out) right after auth. A disabled module returns
* the 403 `module.disabled` envelope before any self-context read or
* write; an enabled module proceeds to the normal 200 path.
* The shared self-context route is available when Coach or Insights can
* consume it. Coach-only work within that route stays behind the Coach gate.
*/
vi.mock("@/lib/api-handler", () => ({
@@ -36,14 +33,46 @@ vi.mock("@/lib/api-response", () => ({
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
const {
profileFindUnique,
profileUpsert,
userUpdate,
transaction,
invalidateInsights,
} = vi.hoisted(() => {
const profileFindUnique = vi.fn();
const profileUpsert = vi.fn(async () => ({ updatedAt: new Date(0) }));
const userUpdate = vi.fn(async () => ({}));
const tx = {
userHealthProfile: {
findUnique: profileFindUnique,
upsert: profileUpsert,
},
user: { update: userUpdate },
};
return {
profileFindUnique,
profileUpsert,
userUpdate,
transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
callback(tx),
),
invalidateInsights: vi.fn(),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
$transaction: transaction,
userHealthProfile: {
findUnique: vi.fn(async () => ({ updatedAt: new Date(0) })),
upsert: vi.fn(async () => ({ updatedAt: new Date(0) })),
findUnique: profileFindUnique,
upsert: profileUpsert,
},
user: { update: userUpdate },
},
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserInsights: invalidateInsights,
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
rateLimitHeaders: vi.fn(() => ({})),
@@ -59,8 +88,20 @@ const emptyCtx = {
};
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextForUser: vi.fn(async () => emptyCtx),
filterSelfContextForAi: (
ctx: Record<string, string | null>,
includedSections: string[],
) => {
const included = new Set(includedSections);
return {
aboutMe: included.has("ABOUT_ME") ? ctx.aboutMe : null,
conditions: included.has("CONDITIONS") ? ctx.conditions : null,
allergies: included.has("ALLERGIES") ? ctx.allergies : null,
coachFocus: included.has("COACH_FOCUS") ? ctx.coachFocus : null,
};
},
getPendingQuestionsForUser: vi.fn(async () => []),
setPendingQuestionsForUser: vi.fn(),
setPendingQuestionsForUser: vi.fn(async () => true),
}));
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
deriveClarifyingQuestions: vi.fn(async () => ({
@@ -70,6 +111,13 @@ vi.mock("@/lib/ai/coach/self-context-questions", () => ({
}));
import { GET, PUT } from "../route";
import {
getPendingQuestionsForUser,
setPendingQuestionsForUser,
getSelfContextForUser,
} from "@/lib/ai/coach/about-me";
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
import { auditLog } from "@/lib/auth/audit";
type Envelope = {
data: unknown;
@@ -87,47 +135,215 @@ const disabledResponse: Envelope = {
meta: { errorCode: "module.disabled", module: "coach" },
};
function putReq(): Request {
function putReq(
body: Record<string, unknown> = {
aboutMe: "I run daily",
aiIncludedSections: ["ABOUT_ME"],
},
): Request {
return new Request("http://localhost/api/coach/about-me", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ aboutMe: "I run daily" }),
body: JSON.stringify(body),
});
}
describe("about-me module gate", () => {
beforeEach(() => vi.clearAllMocks());
it("GET returns 403 module.disabled when Coach is disabled", async () => {
requireModuleEnabled.mockResolvedValue({
enabled: false,
response: disabledResponse,
beforeEach(() => {
vi.clearAllMocks();
profileFindUnique.mockResolvedValue({
updatedAt: new Date(0),
aiIncludedSections: ["ABOUT_ME"],
});
});
function setGates(coach: boolean, insights: boolean) {
requireModuleEnabled.mockImplementation(
async (_userId: string, module: string) =>
module === "coach"
? coach
? { enabled: true }
: { enabled: false, response: disabledResponse }
: insights
? { enabled: true }
: { enabled: false, response: disabledResponse },
);
}
it("GET returns 403 when every self-context consumer is disabled", async () => {
setGates(false, false);
const res = await get();
expect(res.status).toBe(403);
expect(res.meta?.errorCode).toBe("module.disabled");
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach");
expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "insights");
});
it("GET returns 200 when Coach is enabled", async () => {
requireModuleEnabled.mockResolvedValue({ enabled: true });
it("GET remains available to an Insights-only account", async () => {
setGates(false, true);
const res = await get();
expect(res.status).toBe(200);
expect(getPendingQuestionsForUser).not.toHaveBeenCalled();
});
it("PUT returns 403 module.disabled when Coach is disabled", async () => {
requireModuleEnabled.mockResolvedValue({
enabled: false,
response: disabledResponse,
});
it("GET does not consult Insights when Coach is enabled", async () => {
setGates(true, false);
const res = await get();
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalledWith("u1", "insights");
expect(getPendingQuestionsForUser).toHaveBeenCalledWith("u1");
});
it("PUT returns 403 when every self-context consumer is disabled", async () => {
setGates(false, false);
const res = await put(putReq());
expect(res.status).toBe(403);
expect(res.meta?.module).toBe("coach");
});
it("PUT returns 200 when Coach is enabled", async () => {
requireModuleEnabled.mockResolvedValue({ enabled: true });
it("clears stale Coach questions after an Insights-only exclusion save", async () => {
setGates(false, true);
const res = await put(putReq({ aiIncludedSections: ["ABOUT_ME"] }));
expect(res.status).toBe(200);
expect(deriveClarifyingQuestions).not.toHaveBeenCalled();
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
"u1",
null,
new Date(0),
);
expect(userUpdate).toHaveBeenCalledWith({
where: { id: "u1" },
data: {
insightsCachedAt: null,
insightsCachedText: null,
},
});
expect(invalidateInsights).toHaveBeenCalledWith("u1");
});
it("does not commit an exclusion when persistent cache clearing fails", async () => {
setGates(false, true);
const originalSections = ["ABOUT_ME", "CONDITIONS"];
let persistedSections = originalSections;
let stagedSections = persistedSections;
const txProfileUpsert = vi.fn(
async (args: { update: { aiIncludedSections?: string[] } }) => {
stagedSections = args.update.aiIncludedSections ?? stagedSections;
return { updatedAt: new Date(0) };
},
);
transaction.mockImplementationOnce(
async (callback: (client: unknown) => unknown) => {
const result = await callback({
userHealthProfile: {
findUnique: profileFindUnique,
upsert: txProfileUpsert,
},
user: {
update: vi.fn(async () => {
throw new Error("cache clear failed");
}),
},
});
persistedSections = stagedSections;
return result;
},
);
await expect(
put(putReq({ aiIncludedSections: ["ABOUT_ME"] })),
).rejects.toThrow("cache clear failed");
expect(persistedSections).toEqual(originalSections);
expect(txProfileUpsert).toHaveBeenCalledOnce();
expect(profileUpsert).not.toHaveBeenCalled();
expect(invalidateInsights).not.toHaveBeenCalled();
});
it("re-scopes pending questions after an inclusion-only update", async () => {
setGates(true, false);
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
aboutMe: "I run daily",
conditions: null,
allergies: null,
coachFocus: null,
});
const res = await put(putReq({ aiIncludedSections: ["ABOUT_ME"] }));
expect(res.status).toBe(200);
expect(getPendingQuestionsForUser).not.toHaveBeenCalled();
expect(deriveClarifyingQuestions).toHaveBeenCalledWith(
"u1",
{
aboutMe: "I run daily",
conditions: null,
allergies: null,
coachFocus: null,
},
"en",
["ABOUT_ME"],
);
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
"u1",
[],
new Date(0),
);
});
it("clears pending questions when inclusion removes every text field", async () => {
setGates(true, false);
profileFindUnique.mockResolvedValue({
updatedAt: new Date(0),
aiIncludedSections: [],
});
const res = await put(putReq({ aiIncludedSections: [] }));
expect(res.status).toBe(200);
expect(deriveClarifyingQuestions).not.toHaveBeenCalled();
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
"u1",
null,
new Date(0),
);
});
it("audits an excluded nonempty stored field as updated, not cleared", async () => {
setGates(true, false);
profileFindUnique.mockResolvedValue({
updatedAt: new Date(0),
aiIncludedSections: [],
});
vi.mocked(getSelfContextForUser).mockResolvedValueOnce({
aboutMe: null,
conditions: "Asthma",
allergies: null,
coachFocus: null,
});
const res = await put(putReq({ aboutMe: "" }));
expect(res.status).toBe(200);
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
"u1",
null,
new Date(0),
);
expect(auditLog).toHaveBeenCalledWith(
"coach.about_me.updated",
expect.objectContaining({ userId: "u1" }),
);
});
it("PUT retains Coach-only derivation when Coach is enabled", async () => {
setGates(true, false);
const res = await put(putReq());
expect(res.status).toBe(200);
expect(setPendingQuestionsForUser).toHaveBeenCalledWith(
"u1",
null,
new Date(0),
);
});
});
+193 -66
View File
@@ -11,10 +11,9 @@
* 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.
* PUT field semantics: every text field is optional. An omitted field stays
* untouched; an empty string clears it. This also lets the inclusion controls
* change without resubmitting encrypted text that the current key cannot read.
*
* 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
@@ -34,6 +33,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -41,6 +41,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
getPendingQuestionsForUser,
getSelfContextForUser,
filterSelfContextForAi,
setPendingQuestionsForUser,
} from "@/lib/ai/coach/about-me";
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
@@ -50,22 +51,42 @@ import {
ABOUT_ME_MAX_CHARS,
aboutMePutSchema,
} from "@/lib/validations/about-me";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
type HealthProfileAiSection,
} from "@/lib/validations/health-profile-facts";
const PUT_RATE_LIMIT = 30;
const PUT_WINDOW_MS = 60_000;
type SelfContextConsumerGate =
| { enabled: true; coachEnabled: boolean }
| { enabled: false; response: Response };
async function requireSelfContextConsumer(
userId: string,
): Promise<SelfContextConsumerGate> {
const coachGate = await requireModuleEnabled(userId, "coach");
if (coachGate.enabled) return { enabled: true, coachEnabled: true };
const insightsGate = await requireModuleEnabled(userId, "insights");
if (insightsGate.enabled) return { enabled: true, coachEnabled: false };
return coachGate;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.18.0 — Coach module gate (operator availability + disableCoach).
const gate = await requireModuleEnabled(user.id, "coach");
const gate = await requireSelfContextConsumer(user.id);
if (!gate.enabled) return gate.response;
const [ctx, pendingQuestions, row] = await Promise.all([
getSelfContextForUser(user.id),
getPendingQuestionsForUser(user.id),
gate.coachEnabled
? getPendingQuestionsForUser(user.id)
: Promise.resolve<string[]>([]),
prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
select: { updatedAt: true, aiIncludedSections: true },
}),
]);
@@ -86,6 +107,9 @@ export const GET = apiHandler(async () => {
allergies: ctx.allergies,
coachFocus: ctx.coachFocus,
pendingQuestions,
aiIncludedSections:
(row?.aiIncludedSections as HealthProfileAiSection[] | undefined) ??
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
updatedAt: row?.updatedAt?.toISOString() ?? null,
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
@@ -94,8 +118,7 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// v1.18.0 — Coach module gate (operator availability + disableCoach).
const gate = await requireModuleEnabled(user.id, "coach");
const gate = await requireSelfContextConsumer(user.id);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
@@ -130,8 +153,14 @@ export const PUT = apiHandler(async (req: Request) => {
return returnAllZodIssues(parsed.error, 422);
}
const text = parsed.data.aboutMe.trim();
const cleared = text.length === 0;
const text = parsed.data.aboutMe?.trim();
const textFieldsChanged =
parsed.data.aboutMe !== undefined ||
parsed.data.conditions !== undefined ||
parsed.data.allergies !== undefined ||
parsed.data.coachFocus !== undefined;
const questionsNeedRefresh =
textFieldsChanged || parsed.data.aiIncludedSections !== undefined;
// Field-by-field data assembly (no mass assignment): omitted fields
// never appear in the update object, so they stay untouched.
@@ -143,13 +172,16 @@ export const PUT = apiHandler(async (req: Request) => {
return value.length === 0 ? null : encryptToBytes(value);
};
const update: {
aboutMeEncrypted: Uint8Array<ArrayBuffer> | null;
aboutMeEncrypted?: Uint8Array<ArrayBuffer> | null;
conditionsEncrypted?: Uint8Array<ArrayBuffer> | null;
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | null;
} = {
aboutMeEncrypted: cleared ? null : encryptToBytes(text),
};
aiIncludedSections?: HealthProfileAiSection[];
} = {};
const aboutMePayload = encryptOptional(parsed.data.aboutMe);
if (aboutMePayload !== undefined) {
update.aboutMeEncrypted = aboutMePayload;
}
const conditionsPayload = encryptOptional(parsed.data.conditions);
if (conditionsPayload !== undefined) {
update.conditionsEncrypted = conditionsPayload;
@@ -162,8 +194,11 @@ export const PUT = apiHandler(async (req: Request) => {
if (coachFocusPayload !== undefined) {
update.coachFocusEncrypted = coachFocusPayload;
}
if (parsed.data.aiIncludedSections !== undefined) {
update.aiIncludedSections = parsed.data.aiIncludedSections;
}
const fieldLengths: Record<string, number> = {
aboutMe: text.length,
...(text !== undefined ? { aboutMe: text.length } : {}),
...(parsed.data.conditions !== undefined
? { conditions: parsed.data.conditions.trim().length }
: {}),
@@ -175,80 +210,163 @@ export const PUT = apiHandler(async (req: Request) => {
: {}),
};
// v1.32.21 (R5a) — optimistic concurrency on `UserHealthProfile.updatedAt`.
// - no base token → today's upsert (backward-compat arm, byte-identical);
// - base present + row still carries it → conditional update, echo fresh;
// - base present + zero match → the row either vanished (deleted since the
// read → create wins, nothing to clobber) or advanced (real conflict →
// 409, no write).
let updatedAtIso: string;
if (base === undefined) {
const row = await prisma.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, ...update },
update,
select: { updatedAt: true },
});
updatedAtIso = row.updatedAt.toISOString();
} else {
const guarded = await prisma.userHealthProfile.updateMany({
where: { userId: user.id, updatedAt: base },
data: update,
});
if (guarded.count === 0) {
const existing = await prisma.userHealthProfile.findUnique({
// Keep the profile write and the persistent Insights cache clear in the
// same transaction. If either write fails, an inclusion change cannot
// commit while an hour-fresh advisor payload still references excluded
// profile content.
const persistence = await prisma.$transaction(async (tx) => {
let updatedAtIso: string;
let savedProfileUpdatedAt: Date | null = null;
// v1.32.21 (R5a) — optimistic concurrency on
// `UserHealthProfile.updatedAt`.
// - no base token → today's upsert (backward-compat arm);
// - base present + row still carries it → conditional update;
// - base present + zero match → create if the row vanished, otherwise
// report a real conflict without clearing the cache.
if (base === undefined) {
const row = await tx.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, ...update },
update,
select: { updatedAt: true },
});
if (existing) {
annotate({
action: { name: "coach.about_me.conflict" },
meta: { base_updated_at: base.toISOString() },
});
return apiError("Self-context changed since it was loaded", 409, {
errorCode: "about_me_conflict",
});
}
const created = await prisma.userHealthProfile.create({
data: { userId: user.id, ...update },
select: { updatedAt: true },
});
updatedAtIso = created.updatedAt.toISOString();
updatedAtIso = row.updatedAt.toISOString();
savedProfileUpdatedAt = row.updatedAt;
} else {
const fresh = await prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
const guarded = await tx.userHealthProfile.updateMany({
where: { userId: user.id, updatedAt: base },
data: update,
});
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
if (guarded.count === 0) {
const existing = await tx.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
});
if (existing) {
return {
conflict: true as const,
baseUpdatedAt: base.toISOString(),
};
}
const created = await tx.userHealthProfile.create({
data: { userId: user.id, ...update },
select: { updatedAt: true },
});
updatedAtIso = created.updatedAt.toISOString();
savedProfileUpdatedAt = created.updatedAt;
} else {
const fresh = await tx.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
});
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
savedProfileUpdatedAt = fresh?.updatedAt ?? null;
}
}
if (parsed.data.aiIncludedSections !== undefined) {
await tx.user.update({
where: { id: user.id },
data: {
insightsCachedAt: null,
insightsCachedText: null,
},
});
}
return {
conflict: false as const,
updatedAtIso,
savedProfileUpdatedAt,
};
});
if (persistence.conflict) {
annotate({
action: { name: "coach.about_me.conflict" },
meta: { base_updated_at: persistence.baseUpdatedAt },
});
return apiError("Self-context changed since it was loaded", 409, {
errorCode: "about_me_conflict",
});
}
let { updatedAtIso } = persistence;
const { savedProfileUpdatedAt } = persistence;
if (parsed.data.aiIncludedSections !== undefined) {
invalidateUserInsights(user.id);
}
// 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 =
const [ctx, controlRow] = await Promise.all([
getSelfContextForUser(user.id),
prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { aiIncludedSections: true },
}),
]);
const aiIncludedSections = (controlRow?.aiIncludedSections as
HealthProfileAiSection[] | undefined) ?? [
...DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
];
const aiContext = filterSelfContextForAi(ctx, aiIncludedSections);
const aiContextIsEmpty =
aiContext.aboutMe === null &&
aiContext.conditions === null &&
aiContext.allergies === null &&
aiContext.coachFocus === null;
const storedContextIsEmpty =
ctx.aboutMe === null &&
ctx.conditions === null &&
ctx.allergies === null &&
ctx.coachFocus === null;
let pendingQuestions: string[] = [];
let questionsToPersist: string[] | null | undefined;
let questionsSource: "ai" | "fallback" | "none" = "none";
if (isEmpty) {
await setPendingQuestionsForUser(user.id, null);
if (!gate.coachEnabled) {
if (parsed.data.aiIncludedSections !== undefined) {
questionsToPersist = null;
}
} else if (!questionsNeedRefresh) {
pendingQuestions = await getPendingQuestionsForUser(user.id);
} else if (aiContextIsEmpty) {
questionsToPersist = null;
} else {
const derived = await deriveClarifyingQuestions(user.id, ctx, user.locale);
const derived = await deriveClarifyingQuestions(
user.id,
aiContext,
user.locale,
aiIncludedSections,
);
pendingQuestions = derived.questions;
questionsSource = derived.source;
await setPendingQuestionsForUser(user.id, pendingQuestions);
questionsToPersist = pendingQuestions;
}
if (questionsToPersist !== undefined) {
const persisted =
savedProfileUpdatedAt !== null &&
(await setPendingQuestionsForUser(
user.id,
questionsToPersist,
savedProfileUpdatedAt,
));
if (!persisted) {
pendingQuestions = await getPendingQuestionsForUser(user.id);
questionsSource = "none";
}
}
const cleared = textFieldsChanged && storedContextIsEmpty;
// 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",
cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
{
userId: user.id,
ipAddress: getClientIp(req),
@@ -258,21 +376,30 @@ export const PUT = apiHandler(async (req: Request) => {
annotate({
action: {
name: isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
name: cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
},
meta: {
length: text.length,
length: text?.length,
questions_source: questionsSource,
questions_count: pendingQuestions.length,
},
});
const finalProfileRow = await prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
});
if (finalProfileRow) {
updatedAtIso = finalProfileRow.updatedAt.toISOString();
}
return apiSuccess({
aboutMe: ctx.aboutMe,
conditions: ctx.conditions,
allergies: ctx.allergies,
coachFocus: ctx.coachFocus,
pendingQuestions,
aiIncludedSections,
updatedAt: updatedAtIso,
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
@@ -47,6 +47,7 @@ const EXISTING = {
targetHigh: 60,
decimals: 1,
description: null,
correlationEnabled: false,
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
deletedAt: null,
@@ -127,6 +128,26 @@ describe("PATCH /api/custom-metrics/[id]", () => {
expect(updateCall.data.targetHigh).toBe(50);
});
it("persists only the explicit correlation opt-in field", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(
EXISTING as never,
);
vi.mocked(prisma.customMetric.update).mockResolvedValue({
...EXISTING,
correlationEnabled: true,
} as never);
const res = await PATCH(
patchReq({ correlationEnabled: true }),
params("cm-1"),
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.customMetric.update).mock
.calls[0][0] as { data: Record<string, unknown> };
expect(updateCall.data).toEqual({ correlationEnabled: true });
});
it("422s an inverted partial bound update without updating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// existing.targetHigh is 60; moving the low bound above it inverts.
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -91,6 +92,7 @@ export const PATCH = apiHandler(
action: { name: "custom-metric.entry.update" },
meta: { customMetricId: id, entryId },
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess(serialiseCustomMetricEntry(updated));
},
@@ -120,6 +122,7 @@ export const DELETE = apiHandler(
action: { name: "custom-metric.entry.delete" },
meta: { customMetricId: id, entryId },
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess({ deleted: true });
},
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -152,6 +153,7 @@ async function postCustomMetricEntry(
action: { name: "custom-metric.entry.create" },
meta: { customMetricId: id, entryId: created.id },
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess(serialiseCustomMetricEntry(created), 201);
}
+6
View File
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -121,6 +122,9 @@ export const PATCH = apiHandler(
if (d.targetHigh !== undefined) data.targetHigh = d.targetHigh;
if (d.decimals !== undefined) data.decimals = d.decimals;
if (d.description !== undefined) data.description = d.description;
if (d.correlationEnabled !== undefined) {
data.correlationEnabled = d.correlationEnabled;
}
// Inverted-range guard for a PARTIAL bound update. The schema refine only
// fires when both bounds arrive together; moving a single bound past the
@@ -143,6 +147,7 @@ export const PATCH = apiHandler(
action: { name: "custom-metric.metric.update" },
meta: { customMetricId: id },
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess(serialiseCustomMetric(updated));
},
@@ -179,6 +184,7 @@ export const DELETE = apiHandler(
action: { name: "custom-metric.metric.delete" },
meta: { customMetricId: id },
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess({ deleted: true });
},
@@ -65,6 +65,7 @@ const ROW = {
targetHigh: null,
decimals: null,
description: null,
correlationEnabled: false,
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
deletedAt: null,
@@ -108,7 +109,13 @@ describe("POST /api/custom-metrics", () => {
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.customMetric.create).mockResolvedValue(ROW as never);
const res = await POST(postReq({ name: "Grip strength", unit: "kg" }));
const res = await POST(
postReq({
name: "Grip strength",
unit: "kg",
correlationEnabled: true,
}),
);
expect(res.status).toBe(201);
const createCall = vi.mocked(prisma.customMetric.create).mock
.calls[0][0] as {
@@ -117,6 +124,7 @@ describe("POST /api/custom-metrics", () => {
expect(createCall.data.userId).toBe("user-1");
expect(createCall.data.name).toBe("Grip strength");
expect("userId" in createCall.data).toBe(true);
expect(createCall.data.correlationEnabled).toBe(true);
});
it("409s a duplicate live name without creating", async () => {
+13 -2
View File
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -94,8 +95,15 @@ async function postCustomMetric(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
const { name, unit, targetLow, targetHigh, decimals, description } =
parsed.data;
const {
name,
unit,
targetLow,
targetHigh,
decimals,
description,
correlationEnabled,
} = parsed.data;
// The `@@unique([userId, name])` index is the structural backstop and spans
// soft-deleted rows too. Resolve the name up front: a LIVE collision is a
@@ -120,6 +128,7 @@ async function postCustomMetric(request: NextRequest) {
targetHigh: targetHigh ?? null,
decimals: decimals ?? null,
description: description ?? null,
correlationEnabled,
deletedAt: null,
},
})
@@ -132,6 +141,7 @@ async function postCustomMetric(request: NextRequest) {
targetHigh: targetHigh ?? null,
decimals: decimals ?? null,
description: description ?? null,
correlationEnabled,
},
});
@@ -145,6 +155,7 @@ async function postCustomMetric(request: NextRequest) {
action: { name: "custom-metric.metric.create" },
meta: { customMetricId: created.id, revived: existing !== null },
});
invalidateUserCorrelationPatterns(user.id);
// A freshly created metric has no values; a revived one may retain entries
// logged before it was soft-deleted, so resolve the latest + count for it.
@@ -58,7 +58,14 @@ const DIGEST: DailyDigest = {
generatedAt: "2026-07-16T09:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 3 },
score: {
value: 82,
band: "good",
delta: 3,
deltaReason: null,
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
line: "Blood pressure is holding steady.",
@@ -30,6 +30,10 @@ vi.mock("@/lib/rate-limit", () => ({
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserHealthScore: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -56,6 +60,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -129,11 +134,13 @@ describe("POST /api/daily/digest/dismiss", () => {
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => {
it("upserts each approved observational or notice dismissal for the caller", async () => {
for (const itemKey of [
"milestone:record_first:WEIGHT:2026-07-16",
"ecg_new_recording:2026-07-16T08:00:00.000Z",
"tension_window:2026-07-16:afternoon",
"same_time_baseline:2026-07-16:ACTIVITY_STEPS",
"health_score_algorithm:2",
]) {
vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear();
const res = await callPost(makeReq({ itemKey }));
@@ -161,4 +168,12 @@ describe("POST /api/daily/digest/dismiss", () => {
expect(res.status).toBe(429);
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
});
it("evicts score-bearing server caches after the algorithm notice write", async () => {
const res = await callPost(
makeReq({ itemKey: "health_score_algorithm:2" }),
);
expect(res.status).toBe(200);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
});
+9 -6
View File
@@ -1,12 +1,11 @@
/**
* POST /api/daily/digest/dismiss
*
* "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem`
* kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The
* ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`,
* `coach_checkin`) are never reachable here: `dismissPriorityItemSchema`
* rejects any `itemKey` that doesn't carry one of the three dismissible
* kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`).
* "Dismiss / mark seen" for observational Today rail items and versioned
* informational notices. Actionable kinds (`dose_window`, `sync_issue`,
* `preventive_care`, `coach_checkin`) are never reachable here:
* `dismissPriorityItemSchema` rejects any `itemKey` without an approved
* observational or notice prefix before a lookup runs.
*
* Persisted server-side (`DismissedPriorityItem`, composite-unique on
* `userId` + `itemKey`) so the dismissal survives reload / a second device —
@@ -27,6 +26,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
@@ -72,6 +72,9 @@ export const POST = apiHandler(async (req: NextRequest) => {
update: {},
create: { userId: user.id, itemKey },
});
if (itemKey.startsWith("health_score_algorithm:")) {
invalidateUserHealthScore(user.id);
}
annotate({
action: { name: "daily.digest.item.dismissed" },
@@ -11,6 +11,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const transactionUser = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
@@ -22,6 +27,7 @@ vi.mock("@/lib/db", () => ({
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
},
toJson: (v: unknown) => v,
}));
@@ -55,7 +61,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { GET, PUT, __resetAuditDedupMemoForTests } from "../route";
import { DELETE, GET, PUT, __resetAuditDedupMemoForTests } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
@@ -66,13 +72,10 @@ import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
// v1.32.1 — the exact payload builder `ringMutation.mutationFn` calls
// (see the settings component). Importing the shipped function rather
// than re-implementing its shape means this test exercises the REAL
// client contract against the REAL route, not a hand-rolled stand-in.
buildRingMutationPayload,
type DashboardLayout,
type DashboardLayoutWithToken,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -85,6 +88,7 @@ const SESSION_OK = {
};
const callPut = PUT as unknown as (req: NextRequest) => Promise<Response>;
const callDelete = DELETE as unknown as () => Promise<Response>;
function makeReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/dashboard/widgets", {
@@ -100,6 +104,9 @@ beforeEach(() => {
__resetAuditDedupMemoForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.$transaction).mockImplementation((async (
callback: (tx: { user: typeof transactionUser }) => Promise<unknown>,
) => callback({ user: transactionUser })) as never);
});
describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", () => {
@@ -145,6 +152,22 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(paths).toEqual(["comparisonBaseline", "version", "widgets"]);
});
it("rejects layouts above the documented 50-widget runtime cap", async () => {
const widgets = Array.from({ length: 51 }, (_, order) => ({
id: "weight",
visible: true,
tileVisible: true,
order,
}));
const res = await callPut(makeReq({ version: 1, widgets }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(body.details.issues.map((issue) => issue.path)).toContain("widgets");
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("writes one audit-ledger row keyed dashboard.widgets.validation-failed", async () => {
const res = await callPut(makeReq({ version: 2, widgets: [] }));
expect(res.status).toBe(422);
@@ -487,6 +510,23 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
expect(ids.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
});
it("GET preserves an explicit all-off hero-content choice", async () => {
const stored = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds: [],
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: stored,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { enabledHeroItemKinds: string[] };
};
expect(body.data.enabledHeroItemKinds).toEqual([]);
});
it("an id genuinely outside the 27-catalogue still drops on PUT", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
@@ -645,86 +685,66 @@ describe("dashboard widgets — preserve-when-absent on PUT", () => {
});
});
/**
* v1.32.1 — regression for issue #581: dashboard layout changes silently
* overwritten by a concurrent score-ring save.
*
* The scenario from the report: the user edits tile/chart visibility
* (local draft B), then reorders/toggles a hero score ring before hitting
* Save. The ring PUT (`ringMutation`) fires immediately, built from
* whatever layout the client had cached (layout A — the state BEFORE the
* draft edit). The normal Save button then PUTs the full draft (layout B)
* and commits first. If the earlier-fired ring request resolves AFTER
* Save, its body — built from the stale A snapshot — used to carry
* `widgets: A` explicitly, and an explicitly-present field always wins on
* write. Save's B silently reverted to A even though the ring PUT reported
* 200 and only meant to touch the ring selection.
*
* The fix is `buildRingMutationPayload` (imported from the real component
* module, not re-implemented here): it never includes `widgets` at all.
* This test proves the SERVER half of the fix — a PUT shaped exactly like
* the shipped ring mutation's body preserves whatever layout is CURRENTLY
* stored, so the request that resolves last can no longer matter.
*/
describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layout Save (regression #581)", () => {
it("preserves the widgets a concurrent Save already committed, even though the ring request started from a stale snapshot", async () => {
// The state AFTER the normal Save committed layout B — a tile turned
// off that was on in the stale snapshot A the ring mutation started
// from. Starts from the FULL default widget catalogue (rather than a
// single-widget array) so `resolveDashboardLayout`'s auto-upgrade
// append (missing catalogue ids get seeded invisible) is a no-op here
// and the persisted array can be compared for exact equality below.
const savedLayoutB: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) =>
w.id === "weight" ? { ...w, visible: false, tileVisible: false } : w,
),
comparisonBaseline: "lastYear",
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
describe("dashboard widgets — hero-content visibility persistence", () => {
it("preserves the stored choice when a stale client omits the field", async () => {
const stored = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds: ["preventive_care", "milestone"],
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: savedLayoutB,
dashboardWidgetsJson: stored,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// The ring mutation's real, shipped payload — built without any
// knowledge of layout B. If this were still `{...staleA, ...}` the
// stale `widgets`/`comparisonBaseline` would land here explicitly and
// overwrite B; `buildRingMutationPayload` never carries them.
const ringOnlyBody = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
});
const res = await callPut(makeReq(ringOnlyBody));
const res = await callPut(
makeReq({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: DashboardLayout };
};
// B's widgets (and comparisonBaseline) survive — the ring PUT never
// touched them, so nothing it carries can outrace the Save that
// already committed.
expect(updateArg.data.dashboardWidgetsJson.widgets).toEqual(
savedLayoutB.widgets,
);
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
"lastYear",
);
// The ring selection itself DID apply.
expect(updateArg.data.dashboardWidgetsJson.selectedScoreRings).toEqual([
"READINESS",
expect(updateArg.data.dashboardWidgetsJson.enabledHeroItemKinds).toEqual([
"preventive_care",
"milestone",
]);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.widgets[0].visible).toBe(false);
expect(body.data.widgets[0].tileVisible).toBe(false);
expect(body.data.selectedScoreRings).toEqual(["READINESS"]);
});
it("still accepts a normal full-layout Save (widgets present + replace semantics unchanged)", async () => {
it("round-trips an explicit all-off PUT without writing notification preferences", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: DEFAULT_DASHBOARD_LAYOUT,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: [],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown> & {
dashboardWidgetsJson: DashboardLayout;
};
};
expect(updateArg.data.dashboardWidgetsJson.enabledHeroItemKinds).toEqual(
[],
);
expect(Object.keys(updateArg.data)).toEqual(["dashboardWidgetsJson"]);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.enabledHeroItemKinds).toEqual([]);
});
it("omits an all-enabled choice from stored and returned serialization", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
@@ -733,14 +753,39 @@ describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layo
const res = await callPut(
makeReq({
version: 1,
widgets: [
{ id: "weight", visible: false, tileVisible: false, order: 0 },
],
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS].reverse(),
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: DashboardLayout };
};
expect(
Object.prototype.hasOwnProperty.call(
updateArg.data.dashboardWidgetsJson,
"enabledHeroItemKinds",
),
).toBe(false);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.widgets[0].visible).toBe(false);
expect(
Object.prototype.hasOwnProperty.call(body.data, "enabledHeroItemKinds"),
).toBe(false);
});
it("rejects values outside the current priority-item catalogue", async () => {
const res = await callPut(
makeReq({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: ["future_item"],
}),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -763,6 +808,7 @@ describe("dashboard widgets — optimistic concurrency (issue #581)", () => {
chartOverlayPrefs: {},
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
};
}
@@ -841,3 +887,83 @@ describe("dashboard widgets — optimistic concurrency (issue #581)", () => {
expect(body.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
});
describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
it("resets tiles and Today highlights while preserving stored advanced preferences", async () => {
const stored = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((widget, order) => ({
...widget,
visible: false,
tileVisible: false,
order: DEFAULT_DASHBOARD_LAYOUT.widgets.length - order,
})),
comparisonBaseline: "lastYear",
chartOverlayPrefs: {
weight: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: false,
comparisonBaseline: "lastMonth",
rangePoints: 90,
},
},
selectedScoreRings: ["READINESS", "SLEEP_SCORE"],
heroRingOrder: ["SLEEP_SCORE", "HEALTH_SCORE", "READINESS"],
enabledHeroItemKinds: ["milestone"],
});
const advanced = new Date("2026-07-24T12:00:00.000Z");
transactionUser.findUnique.mockResolvedValue({
dashboardWidgetsJson: stored,
});
transactionUser.update.mockResolvedValue({ updatedAt: advanced });
const res = await callDelete();
expect(res.status).toBe(200);
expect(prisma.$transaction).toHaveBeenCalledOnce();
expect(vi.mocked(prisma.$transaction).mock.calls[0]?.[1]).toEqual({
isolationLevel: "Serializable",
});
expect(prisma.user.findUnique).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(transactionUser.findUnique).toHaveBeenCalledOnce();
expect(transactionUser.update).toHaveBeenCalledOnce();
const updateArg = transactionUser.update.mock.calls[0]?.[0] as {
data: { dashboardWidgetsJson: DashboardLayout };
select: { updatedAt: true };
};
expect(updateArg.select).toEqual({ updatedAt: true });
const persisted = updateArg.data.dashboardWidgetsJson;
expect(persisted.widgets).toEqual(DEFAULT_DASHBOARD_LAYOUT.widgets);
expect(
Object.prototype.hasOwnProperty.call(persisted, "enabledHeroItemKinds"),
).toBe(false);
expect(persisted.comparisonBaseline).toBe("lastYear");
expect(persisted.chartOverlayPrefs).toEqual({
weight: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: false,
comparisonBaseline: "lastMonth",
rangePoints: 90,
},
});
expect(persisted.selectedScoreRings).toEqual(["READINESS", "SLEEP_SCORE"]);
expect(persisted.heroRingOrder).toEqual([
"SLEEP_SCORE",
"HEALTH_SCORE",
"READINESS",
]);
const body = (await res.json()) as { data: DashboardLayoutWithToken };
expect(body.data.widgets).toEqual(DEFAULT_DASHBOARD_LAYOUT.widgets);
expect(body.data.enabledHeroItemKinds).toEqual([...PRIORITY_ITEM_KINDS]);
expect(body.data.comparisonBaseline).toBe("lastYear");
expect(body.data.chartOverlayPrefs).toEqual(persisted.chartOverlayPrefs);
expect(body.data.selectedScoreRings).toEqual(persisted.selectedScoreRings);
expect(body.data.heroRingOrder).toEqual(persisted.heroRingOrder);
expect(body.data.updatedAt).toBe(advanced.toISOString());
});
});
+55 -46
View File
@@ -2,8 +2,8 @@
* GET / PUT / DELETE dashboard widget layout.
*
* GET returns the resolved effective layout (defaults merged in if the user
* hasn't customized yet). PUT replaces the layout atomically. DELETE resets
* to default.
* hasn't customized yet). PUT replaces the supplied layout fields atomically.
* DELETE resets web-controlled choices and preserves the other preferences.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -36,7 +36,7 @@ import {
type DashboardLayout,
type DashboardLayoutWithToken,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -81,6 +81,7 @@ const layoutSchema = z.object({
}),
)
.min(1)
.max(50)
// v1.11.2 B5 — raised to 40 so the full catalogue PUT (24 writable +
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
// HealthKit signals became pinnable; the enum still bounds each id to
@@ -90,14 +91,9 @@ const layoutSchema = z.object({
// catalogue (42 ids). The cap only has to clear the catalogue size —
// the enum bounds each entry and the id set is closed — so it carries
// headroom rather than tracking the count exactly.
.max(50)
// v1.32.1 — optional + preserve-when-absent (issue #581). The
// Settings page's instant score-ring toggle now PUTs ONLY the ring
// fields; omitting `widgets` lets the server carry forward whatever
// is CURRENTLY stored instead of the ring mutation resending a
// client-held snapshot that can go stale mid-flight and race a
// concurrent full-layout Save. A normal Save still always sends
// `widgets` — this only widens what a PARTIAL PUT can leave out.
// Optional + preserve-when-absent. Partial clients can update one layout
// field without resending a potentially stale widget catalogue; a normal
// dashboard-layout Save still sends `widgets`.
.optional(),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
@@ -156,6 +152,12 @@ const layoutSchema = z.object({
.array(z.enum(HERO_RING_IDS))
.max(MAX_HERO_RING_ORDER)
.optional(),
// Today hero item visibility. Empty is an explicit all-off choice; omission
// preserves the stored value for clients that predate this field.
enabledHeroItemKinds: z
.array(z.enum(PRIORITY_ITEM_KINDS))
.max(PRIORITY_ITEM_KINDS.length)
.optional(),
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
// which strips it BEFORE this parse: the token is transport, not a layout
@@ -313,29 +315,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
// Preserve-when-absent: every field the client didn't send is carried
// forward from the stored layout rather than clamped to its default by
// the serializer. The dashboard-layout PUT typically saves widget
// visibility / order, while chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
// Settings, and `comparisonBaseline` is web-only — an omission from any
// one surface must not reset a choice made on another. `widgets` itself
// joined this set in v1.32.1 (issue #581): the Settings page's instant
// score-ring PUT omits it entirely so it always carries forward
// whatever is CURRENTLY stored, rather than a client-held snapshot that
// could be stale relative to a concurrent full-layout Save.
// Preserve every omitted client-owned field from the stored layout instead
// of clamping it to a default. The disposition map is exhaustive over
// `DashboardLayout`, so adding a field requires an explicit merge decision.
// One stored-layout read covers every fallback. The read is skipped only
// when the client supplied every preserved field and every supplied chart
// preference has rangePoints.
//
// The preserve set is not a list maintained here: it is derived from
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
// every field of `DashboardLayout`. Adding a layout field without a
// disposition fails typecheck, so the next field cannot silently miss this
// merge the way `comparisonBaseline` did. One stored-layout read covers
// every fallback, and the read is skipped only when the client sent every
// preserved top-level field and every supplied chart pref has rangePoints.
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
// serializer fills the gap, so the assertion is safe here exactly as it was
// on the previous per-field cast.
// The Zod shape leaves per-chart `comparisonBaseline` optional while
// `ChartOverlayPrefs` requires it. `coerceChartOverlayPrefsMap` inside the
// serializer fills that gap, so the assertion below is safe.
// v1.32.16 (issue #581) — the concurrency token was already split off the
// body pre-Zod (`takeBaseToken`), so `parsed.data` is pure layout and can
// never leak the token into the stored blob.
@@ -387,14 +376,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
// v1.32.16 (issue #581) / v1.32.21 (R5a) — optimistic concurrency through
// the shared `guardedUserUpdate` helper. When the client sends the
// `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on the
// stored row still carrying that token, so a Save that already committed
// can never be silently reverted by a later request (e.g. an instant
// hero-ring PUT) that started from the pre-Save snapshot. A stale base
// returns 409 and writes NOTHING. Clients that omit the token (older web
// builds, the native client) keep the prior unconditional write.
// Optimistic concurrency through the shared `guardedUserUpdate` helper.
// When a client sends the `baseUpdatedAt` token it read with the layout, the
// write succeeds only while the stored row still carries that token. A stale
// base returns 409 without writing. Clients that omit the token keep the
// legacy unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
@@ -427,10 +413,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await prisma.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: Prisma.JsonNull },
});
const { normalized, updatedAt } = await prisma.$transaction(
async (tx) => {
const existing = await tx.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
const normalized = serializeDashboardLayout({
...existingLayout,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds,
});
const updated = await tx.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: toJson(normalized) },
select: { updatedAt: true },
});
return { normalized, updatedAt: updated.updatedAt.toISOString() };
},
{ isolationLevel: "Serializable" },
);
annotate({ action: { name: "dashboard.widgets.reset" } });
@@ -438,5 +444,8 @@ export const DELETE = apiHandler(async () => {
// next dashboard mount paints the reset (default) layout.
invalidateUserDashboardWidgets(user.id);
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
return apiSuccess({
...resolveDashboardLayout(normalized),
updatedAt,
} satisfies DashboardLayoutWithToken);
});
@@ -18,6 +18,15 @@ vi.mock("@/lib/db", () => ({
moodEntry: { findMany: vi.fn() },
moodTag: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
// Durable self-context + user-defined metrics, read by
// `buildProfileBackupSection`.
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
healthProfileFactRevision: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
correlationPattern: { findMany: vi.fn().mockResolvedValue([]) },
// The hourly shape of a cumulative day, read by
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -73,9 +82,20 @@ function mkReq(url: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the inline default, and the full-backup payload
// builder reads this unconditionally.
// `resetAllMocks` clears the inline defaults, and the full-backup payload
// builder reads these unconditionally.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.healthProfileFactRevision.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.correlationPattern.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue(
[] as never,
);
});
afterEach(() => {
@@ -16,6 +16,15 @@ vi.mock("@/lib/db", () => ({
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
moodTag: { findMany: vi.fn().mockResolvedValue([]) },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
// Durable self-context + user-defined metrics, read by
// `buildProfileBackupSection`.
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
healthProfileFactRevision: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
correlationPattern: { findMany: vi.fn().mockResolvedValue([]) },
// The hourly shape of a cumulative day, read by
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
+4
View File
@@ -117,6 +117,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
export_intraday_profile_count: counts.intradayProfiles,
export_archive_bytes: archive.byteLength,
},
});
+4
View File
@@ -76,6 +76,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
export_workout_count: counts.workouts,
export_document_count: counts.documents,
export_nutrient_day_count: counts.nutrientDays,
export_health_profile_count: counts.healthProfile,
export_custom_metric_count: counts.customMetrics,
export_custom_metric_entry_count: counts.customMetricEntries,
export_intraday_profile_count: counts.intradayProfiles,
},
});
@@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({
illnessEpisode: { findMany: vi.fn() },
allergy: { findMany: vi.fn() },
familyHistoryEntry: { findMany: vi.fn() },
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
toJson: (value: unknown) => value,
@@ -48,6 +50,11 @@ vi.mock("@/lib/cycle/export-data", () => ({
})),
}));
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn((payload: Uint8Array) =>
Buffer.from(payload).toString("utf8"),
),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -125,6 +132,15 @@ async function pdfText(res: Response): Promise<string> {
}
}
async function pdfBytesText(bytes: Uint8Array): Promise<string> {
const parser = new PDFParse({ data: bytes });
try {
return (await parser.getText()).text;
} finally {
await parser.destroy();
}
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveModuleMap).mockResolvedValue({} as never);
@@ -146,6 +162,12 @@ beforeEach(() => {
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.healthProfileFactRevision.findMany).mockResolvedValue(
[] as never,
);
// First findUnique = aggregator profile select; second = route KVNR select.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
@@ -583,6 +605,94 @@ describe("POST /api/export/health-record — the selection reaches every format"
expect(types).not.toContain("AllergyIntolerance");
expect(types).not.toContain("FamilyMemberHistory");
});
it("ships selected anamnesis in both package artefacts and omits it otherwise", async () => {
const encrypted = (value: string) => new TextEncoder().encode(value);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue({
conditionsEncrypted: encrypted("Asthma"),
} as never);
vi.mocked(prisma.healthProfileFactRevision.findMany).mockResolvedValue([
{
kind: "SMOKING_STATUS",
valueEncrypted: encrypted("FORMER"),
},
{
kind: "ALCOHOL_PATTERN",
valueEncrypted: encrypted("OCCASIONAL"),
},
{
kind: "SHIFT_SCHEDULE",
valueEncrypted: encrypted("ROTATING"),
},
] as never);
const { POST } = await import("../route");
const selected = await POST(
mkReq({
format: "package",
locale: "en",
selection: sel("ANAMNESIS"),
}),
);
expect(selected.status).toBe(200);
const selectedFiles = unzipSync(
new Uint8Array(await selected.arrayBuffer()),
);
const selectedPdf = await pdfBytesText(selectedFiles["report.pdf"]!);
expect(selectedPdf).toContain("Asthma");
expect(selectedPdf).toContain("Former smoker");
expect(selectedPdf).toContain("Occasional");
expect(selectedPdf).toContain("Rotating shifts");
const selectedBundle = JSON.parse(
new TextDecoder().decode(selectedFiles["bundle.json"]!),
) as {
entry: {
resource: {
resourceType: string;
code?: { coding?: { code?: string }[] };
valueCodeableConcept?: { text?: string };
};
}[];
};
const selectedFacts = selectedBundle.entry
.map((entry) => entry.resource)
.filter(
(resource) =>
resource.resourceType === "Observation" &&
["72166-2", "11331-6", "74159-5"].includes(
resource.code?.coding?.[0]?.code ?? "",
),
);
expect(
selectedFacts.map((fact) => fact.valueCodeableConcept?.text),
).toEqual(["Former smoker", "Occasional", "Rotating shifts"]);
vi.mocked(prisma.userHealthProfile.findUnique).mockClear();
vi.mocked(prisma.healthProfileFactRevision.findMany).mockClear();
const omitted = await POST(
mkReq({
format: "package",
locale: "en",
selection: sel("PULSE"),
}),
);
expect(omitted.status).toBe(200);
const omittedFiles = unzipSync(new Uint8Array(await omitted.arrayBuffer()));
const omittedPdf = await pdfBytesText(omittedFiles["report.pdf"]!);
expect(omittedPdf).not.toContain("Asthma");
const omittedBundle = JSON.parse(
new TextDecoder().decode(omittedFiles["bundle.json"]!),
) as typeof selectedBundle;
expect(
omittedBundle.entry.some((entry) =>
["72166-2", "11331-6", "74159-5"].includes(
entry.resource.code?.coding?.[0]?.code ?? "",
),
),
).toBe(false);
expect(prisma.userHealthProfile.findUnique).not.toHaveBeenCalled();
expect(prisma.healthProfileFactRevision.findMany).not.toHaveBeenCalled();
});
});
/**
@@ -52,6 +52,8 @@ vi.mock("@/lib/fhir/resources", () => ({
coverageResource: vi.fn(() => null),
observationsFromReportData: vi.fn(() => []),
cycleObservationsFromReportData: vi.fn(() => []),
anamnesisObservationsFromReportData: vi.fn(() => []),
anamnesisNarrativeFromReportData: vi.fn(() => null),
conditionsFromReportData: vi.fn(() => ({ conditions: [], encounters: [] })),
medicationStatementsFromReportData: vi.fn(() => []),
medicationAdministrationsFromReportData: vi.fn(() => []),
+6 -2
View File
@@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
/**
@@ -58,6 +59,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const { imported } = await syncUserFitbit(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
// The verdict is resolved here, not in the card: a run that imported nothing
// because every resource failed is not the same answer as a quiet hour, and
// both used to render as a green tick with a count.
const result = await syncUserFitbit(user.id, { fullSync });
return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
@@ -56,6 +56,28 @@ describe("POST /api/google-health/sync", () => {
expect(envelope.error).toBe("Google Health sync failed");
});
it("reports a run that failed after writing some resources as a partial", async () => {
// A 502 threw the honest half away: rows DID land, and the card showed the
// generic error instead of "imported N, some data is behind".
vi.mocked(syncUserGoogleHealth).mockResolvedValue({
imported: 7,
failed: true,
});
const response = await POST(request());
const envelope = (await response.json()) as {
data: { imported: number; failed: boolean; outcome: string };
error: string | null;
};
expect(response.status).toBe(200);
expect(envelope.data).toMatchObject({
imported: 7,
failed: true,
outcome: "partial",
});
});
it("keeps the successful imported-count response unchanged", async () => {
vi.mocked(syncUserGoogleHealth).mockResolvedValue({
imported: 7,
@@ -70,7 +92,7 @@ describe("POST /api/google-health/sync", () => {
expect(response.status).toBe(200);
expect(envelope).toEqual({
data: { imported: 7, fullSync: false },
data: { imported: 7, failed: false, outcome: "success", fullSync: false },
error: null,
});
expect(syncUserGoogleHealth).toHaveBeenCalledWith("u1", {
+7 -5
View File
@@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
/**
@@ -55,11 +56,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const { imported, failed } = await syncUserGoogleHealth(user.id, {
fullSync,
});
if (failed) {
const result = await syncUserGoogleHealth(user.id, { fullSync });
// A run that failed AND wrote nothing is an error, as it always was. A run
// that failed after writing some of its resources is a partial, and answering
// 502 for it threw away the honest half of the result.
if (result.failed && result.imported === 0) {
return apiError("Google Health sync failed", 502);
}
return apiSuccess({ imported, fullSync });
return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
@@ -118,6 +118,10 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
// `insights` module, not the Coach surface. Same posture as health-status /
// labs-changes directly above — disabling Coach must not wedge this read.
"src/app/api/insights/pulse/intraday/route.ts",
// Persisted correlation decisions are deterministic Insights data. They
// gate on the Insights module and remain usable when Coach is disabled.
"src/app/api/insights/patterns/route.ts",
"src/app/api/insights/patterns/[id]/route.ts",
// v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach
// mutations. These live under /insights/chat for URL grouping but are
// DOCUMENT-surface routes: they gate on the `inboundDocuments` module
@@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({
// channels in (read from EnvironmentContext); default empty so no env
// channel produces a point.
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -55,6 +56,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/insights/correlation-patterns", () => ({
PATTERN_FAMILIES: {
discoveryRetrospective: "DISCOVERY_RETROSPECTIVE",
discoveryRecent: "DISCOVERY_RECENT",
},
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
decisionForEvidence: vi.fn().mockReturnValue(null),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -120,6 +130,7 @@ beforeEach(() => {
(
prisma.environmentContext.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
+76 -11
View File
@@ -38,9 +38,15 @@ import {
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
import {
decisionForEvidence,
PATTERN_FAMILIES,
syncAcceptedPatterns,
} from "@/lib/insights/correlation-patterns";
import {
buildMeasurementDailySeries,
fetchComplianceSeries,
fetchCustomMetricBehaviourSeries,
fetchEnvironmentSeries,
fetchLabDraws,
fetchMeasurementWindowSeries,
@@ -126,16 +132,22 @@ export const GET = apiHandler(async () => {
// their own sources. Each degrades to an empty series when the user has no
// data, so the discovery loop drops the channel (it cannot clear n ≥ 20).
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside.
const [complianceSeries, symptomSeries, labDraws, environmentSeries] =
await Promise.all([
fetchComplianceSeries(user.id, tz, since),
fetchSymptomSeries(user.id, tz, since),
fetchLabDraws(user.id, tz, since),
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
// daylight). Empty when the module is off / no home set, so the channels
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
fetchEnvironmentSeries(user.id, since),
]);
const [
complianceSeries,
symptomSeries,
labDraws,
environmentSeries,
customMetricSeries,
] = await Promise.all([
fetchComplianceSeries(user.id, tz, since),
fetchSymptomSeries(user.id, tz, since),
fetchLabDraws(user.id, tz, since),
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
// daylight). Empty when the module is off / no home set, so the channels
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
fetchEnvironmentSeries(user.id, since),
fetchCustomMetricBehaviourSeries(user.id, tz, since),
]);
const points = (key: string): DailySeriesPoint[] =>
key === "MOOD"
@@ -170,6 +182,9 @@ export const GET = apiHandler(async () => {
for (const envSeries of environmentSeries) {
series.push(envSeries);
}
for (const customSeries of customMetricSeries) {
series.push(customSeries);
}
const result = discoverCorrelations(series, { locale });
@@ -191,6 +206,46 @@ export const GET = apiHandler(async () => {
locale,
});
const toEvidence = (pattern: (typeof result.discovered)[number]) => ({
factorKey: pattern.behaviour,
outcomeKey: pattern.outcome,
lagDays: pattern.lagDays,
sampleSize: pattern.n,
effectSize: pattern.r,
pValue: pattern.pValue,
qValue: pattern.qValue,
});
const [retrospectiveDecisions, recentDecisions] = await Promise.all([
syncAcceptedPatterns({
userId: user.id,
family: PATTERN_FAMILIES.discoveryRetrospective,
accepted: result.discovered.map(toEvidence),
computedAt: now,
}),
syncAcceptedPatterns({
userId: user.id,
family: PATTERN_FAMILIES.discoveryRecent,
accepted: emerging.emerging.map(toEvidence),
computedAt: now,
}),
]);
const attachPatternDecision = (
pattern: (typeof result.discovered)[number],
decisions: typeof retrospectiveDecisions,
) => {
const decision = decisionForEvidence(decisions, toEvidence(pattern));
return decision ? { ...pattern, ...decision } : pattern;
};
const discovered = result.discovered.map((pattern) =>
attachPatternDecision(pattern, retrospectiveDecisions),
);
const emergingWithDecisions = {
...emerging,
emerging: emerging.emerging.map((pattern) =>
attachPatternDecision(pattern, recentDecisions),
),
};
annotate({
action: { name: "insights.correlations.discover" },
meta: {
@@ -220,8 +275,18 @@ export const GET = apiHandler(async () => {
(sum, s) => sum + s.points.length,
0,
),
custom_metric_channels: customMetricSeries.length,
custom_metric_days: customMetricSeries.reduce(
(sum, channel) => sum + channel.points.length,
0,
),
},
});
return apiSuccess({ ...result, emerging, labCorrelations });
return apiSuccess({
...result,
discovered,
emerging: emergingWithDecisions,
labCorrelations,
});
});
-4
View File
@@ -104,11 +104,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
}
// Profile read once via the shared loader (the same one the batch route
// and the nightly score jobs use), passed into the pure compute function
// — never re-fetched per metric.
const profile = await loadBaselineProfile(prisma, user.id);
const derived = await computeDerivedMetric({
metric,
userId: user.id,
@@ -31,6 +31,8 @@ vi.mock("@/lib/feature-flags", () => ({
AssistantDisabledError: class extends Error {},
}));
const selfContextText = vi.hoisted(() => vi.fn<() => Promise<string | null>>());
vi.mock("@/lib/db", () => ({
prisma: {
// The briefing path now reserves against the day's token ledger before
@@ -46,7 +48,7 @@ vi.mock("@/lib/db", () => ({
insightsCachedText: null,
locale: "en",
})),
update: vi.fn(async () => ({})),
updateMany: vi.fn(async () => ({ count: 1 })),
},
auditLog: {
// v1.4.16 A7: route now evicts stale per-status cache rows
@@ -71,6 +73,11 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: selfContextText,
buildAboutMeInsightBlock: vi.fn(() => ""),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn(async () => undefined),
}));
@@ -178,6 +185,7 @@ beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockImplementation((async () => [
{ total_tokens: 0 },
]) as never);
selfContextText.mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
@@ -255,7 +263,7 @@ describe("POST /api/insights/generate — daily token ceiling", () => {
expect(body.error).toContain("Daily AI token budget");
// Not a provider failure: no cache row written, so the last good briefing
// stays intact and the card does not blank.
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
});
@@ -432,8 +440,8 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.user.update).mock.calls[0][0] as {
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.user.updateMany).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(args.data.insightsCachedText).toEqual(expect.any(String));
@@ -441,6 +449,89 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/);
});
it("refuses a stale cache write when AI profile inclusion changes during generation", async () => {
makeWorkingProvider();
selfContextText
.mockResolvedValueOnce("Chronic conditions: Asthma")
.mockResolvedValueOnce(null);
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(409);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
it("commits successfully even when an unrelated User update (e.g. a provider credential refresh) lands mid-generation", async () => {
// resolveProviderChain()'s token-maintenance write (and any other User
// update outside the prompt-scope columns) bumps `updatedAt` but must
// never collide with this guard — the where-clause below no longer
// references it at all.
makeWorkingProvider();
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsExcludeMetrics: ["sleep"],
locale: "en",
dateOfBirth: null,
gender: "FEMALE",
heightCm: 170,
displayName: "Sam",
} as never);
// The real DB returns count: 1 here too, since the exact-field
// predicate never touches `updatedAt` — a concurrent OAuth
// token-refresh write to the row cannot make it miss.
vi.mocked(prisma.user.updateMany).mockResolvedValueOnce({ count: 1 });
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const where = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(where).not.toHaveProperty("updatedAt");
expect(where).toMatchObject({
insightsPrivacyMode: "aggregated",
insightsExcludeMetrics: { equals: ["sleep"] },
locale: "en",
dateOfBirth: null,
gender: "FEMALE",
heightCm: 170,
displayName: "Sam",
});
expect(enqueueStatusRefillForUser).toHaveBeenCalled();
});
it("refuses a stale cache write when an exact prompt-scope field (privacy mode / exclude list / locale / profile) changed mid-generation", async () => {
makeWorkingProvider();
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
insightsExcludeMetrics: [],
locale: "en",
} as never);
// Simulates a genuine concurrent settings change (e.g. the exclude
// list edited) between the read above and the commit: the guarded
// write's exact-field predicate now matches zero rows.
vi.mocked(prisma.user.updateMany).mockResolvedValueOnce({ count: 0 });
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(409);
expect(prisma.user.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
insightsPrivacyMode: "aggregated",
insightsExcludeMetrics: { equals: [] },
locale: "en",
}),
}),
);
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
});
it("does NOT touch the cache when serving from the 24h DB cache", async () => {
// Cached path: route returns early without touching the LLM or the
// cache write. v1.28.30 — the short-circuit requires the cached
@@ -460,7 +551,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
});
@@ -547,7 +638,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("POST without force + fresh cache WITHOUT briefing + provider → regenerates", async () => {
@@ -560,7 +651,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
// The stale-for-briefing cache was bypassed: a real generation ran and
// wrote a fresh cache row.
expect(body.data.cached).toBe(false);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
});
it("POST without force + fresh cache WITHOUT briefing + NO provider → serves cached (regeneration is futile)", async () => {
@@ -571,7 +662,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("degrades to the cached payload instead of 429 when the briefingless fall-through is rate-limited", async () => {
@@ -589,7 +680,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("explicit force keeps the honest 429 when rate-limited", async () => {
+39 -18
View File
@@ -301,6 +301,23 @@ export const POST = apiHandler((request: NextRequest) =>
aiResponseTimeoutSeconds: true,
},
});
const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated";
// v1.34 — the commit guard below (line ~966) matches on these exact
// columns instead of the broad `updatedAt` timestamp. Every User write
// bumps `updatedAt`, including `resolveProviderChain()`'s OAuth
// token-refresh update later in this same request — keying the
// optimistic-concurrency check on it produced a false 409 whenever a
// provider credential happened to refresh mid-generation. These are
// exactly the columns selected above that feed the prompt built below;
// capturing them here and requiring an exact match at commit still
// catches a genuine privacy-mode / exclude-list / locale / profile
// change without colliding on unrelated column writes.
const excludeMetricsAtRead = dbUser?.insightsExcludeMetrics ?? [];
const localeAtRead = dbUser?.locale ?? null;
const dateOfBirthAtRead = dbUser?.dateOfBirth ?? null;
const genderAtRead = dbUser?.gender ?? null;
const heightCmAtRead = dbUser?.heightCm ?? null;
const displayNameAtRead = dbUser?.displayName ?? null;
const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
dbUser?.aiResponseTimeoutSeconds,
@@ -949,27 +966,26 @@ export const POST = apiHandler((request: NextRequest) =>
);
}
await prisma.user.update({
where: { id: userId },
const currentSelfContext = await getSelfContextTextForUser(userId, locale);
if (currentSelfContext !== aboutMe) {
return apiError("Profile scope changed during insight generation", 409, {
errorCode: "insights.generate.profileScopeChanged",
});
}
const committed = await prisma.user.updateMany({
where: {
id: userId,
insightsPrivacyMode: modeAtRead,
insightsExcludeMetrics: { equals: excludeMetricsAtRead },
locale: localeAtRead,
dateOfBirth: dateOfBirthAtRead,
gender: genderAtRead,
heightCm: heightCmAtRead,
displayName: displayNameAtRead,
},
data: {
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(insights),
// v1.16.8 — store the snapshot fingerprint so the nightly / forced
// regeneration paths can detect "nothing changed" and skip the
// provider call. The user-initiated POST itself stays un-gated: an
// explicit regenerate request is honoured even on unchanged data
// (it is already bounded by the hourly rate limit above). The
// composite shape MUST match the gate in `comprehensive-generate.ts`,
// or every off-request warm after a manual regenerate re-pays a
// full generation on unchanged data. Beyond the features, three
// prompt inputs that can change with NO data change join in:
// - aboutMe (Coach remember / Settings → AI edit),
// - comparisonBaseline (the comparison toggle adds/removes the
// prior-period context block; `null` snapshot means "none"),
// - generationLocale (the language the cached text renders in —
// hashing it makes a locale switch regenerate exactly once, so
// the briefing follows the reader; named `generationLocale`
// because the canonicaliser strips `locale` keys as volatile).
insightsSnapshotHash: hashInsightSnapshot({
features: compactFeatures,
aboutMe: aboutMe ?? null,
@@ -978,6 +994,11 @@ export const POST = apiHandler((request: NextRequest) =>
}),
},
});
if (committed.count === 0) {
return apiError("Insight scope changed during generation", 409, {
errorCode: "insights.generate.scopeChanged",
});
}
// v1.28.30 — persist the grounding omission (marker AFTER the cache
// write so it is newer than `insightsCachedAt`). The transient
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ModuleGateModule from "@/lib/modules/gate";
vi.mock("@/lib/db", () => ({
prisma: {
correlationPattern: { findFirst: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn().mockResolvedValue({}) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof ModuleGateModule>()),
requireModuleEnabled: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
const SESSION = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "owner-1", username: "owner", role: "USER", locale: "en" },
};
const PATTERN = {
id: "pattern-1",
userId: "owner-1",
canonicalKey: `p1:${"a".repeat(64)}`,
evidenceHash: "b".repeat(64),
effectSize: 0.34,
sampleSize: 42,
dismissedAt: null,
};
const params = { params: Promise.resolve({ id: PATTERN.id }) };
function request(body: unknown) {
return new NextRequest(
`http://localhost/api/insights/patterns/${PATTERN.id}`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
describe("PATCH /api/insights/patterns/[id]", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
it("refuses a dismissal when Insights is disabled", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: apiError('Module "insights" is not enabled', 403),
});
const response = await PATCH(request({ dismissed: true }), params);
expect(response.status).toBe(403);
expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled();
});
it("stores the current evidence as the account-scoped dismissal baseline", async () => {
vi.mocked(prisma.correlationPattern.findFirst).mockResolvedValue(
PATTERN as never,
);
vi.mocked(prisma.correlationPattern.update).mockResolvedValue({
...PATTERN,
dismissedAt: new Date("2026-07-28T10:00:00.000Z"),
} as never);
const response = await PATCH(request({ dismissed: true }), params);
expect(response.status).toBe(200);
expect(prisma.correlationPattern.findFirst).toHaveBeenCalledWith({
where: { id: PATTERN.id, userId: "owner-1", isCurrent: true },
});
expect(prisma.correlationPattern.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: PATTERN.id },
data: expect.objectContaining({
dismissedEvidenceHash: PATTERN.evidenceHash,
dismissedEffectSize: PATTERN.effectSize,
dismissedSampleSize: PATTERN.sampleSize,
}),
}),
);
});
it("does not reveal a pattern outside the authenticated account", async () => {
vi.mocked(prisma.correlationPattern.findFirst).mockResolvedValue(null);
const response = await PATCH(request({ dismissed: true }), params);
expect(response.status).toBe(404);
expect(prisma.correlationPattern.update).not.toHaveBeenCalled();
});
it("rejects fields other than the dismissal decision", async () => {
const response = await PATCH(
request({ dismissed: true, factorKey: "OTHER" }),
params,
);
expect(response.status).toBe(422);
expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,95 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { updateCorrelationPatternSchema } from "@/lib/validations/correlation-patterns";
type RouteParams = { params: Promise<{ id: string }> };
const patternIdSchema = z.string().min(1).max(128);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const parsedId = patternIdSchema.safeParse((await params).id);
if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422);
const id = parsedId.data;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = updateCorrelationPatternSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const pattern = await prisma.correlationPattern.findFirst({
where: { id, userId: user.id, isCurrent: true },
});
if (!pattern) return apiError("Correlation pattern not found", 404);
const dismissedAt = parsed.data.dismissed ? new Date() : null;
const updated = await prisma.correlationPattern.update({
where: { id: pattern.id },
data: parsed.data.dismissed
? {
dismissedAt,
dismissedEvidenceHash: pattern.evidenceHash,
dismissedEffectSize: pattern.effectSize,
dismissedSampleSize: pattern.sampleSize,
}
: {
dismissedAt: null,
dismissedEvidenceHash: null,
dismissedEffectSize: null,
dismissedSampleSize: null,
},
select: {
id: true,
canonicalKey: true,
dismissedAt: true,
evidenceHash: true,
},
});
await auditLog(
parsed.data.dismissed
? "correlationPattern.dismiss"
: "correlationPattern.restore",
{
userId: user.id,
ipAddress: getClientIp(request),
details: { correlationPatternId: pattern.id },
},
);
annotate({
action: {
name: parsed.data.dismissed
? "insights.pattern.dismiss"
: "insights.pattern.restore",
},
meta: { correlationPatternId: pattern.id },
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess({
id: updated.id,
canonicalKey: updated.canonicalKey,
dismissed: updated.dismissedAt != null,
dismissedAt: updated.dismissedAt?.toISOString() ?? null,
evidenceHash: updated.evidenceHash,
});
},
);
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as ModuleGateModule from "@/lib/modules/gate";
vi.mock("@/lib/db", () => ({
prisma: {
correlationPattern: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof ModuleGateModule>()),
requireModuleEnabled: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
const SESSION = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "owner-1", username: "owner", role: "USER", locale: "en" },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(prisma.correlationPattern.findMany).mockResolvedValue([]);
});
describe("GET /api/insights/patterns", () => {
it("lists only the authenticated account's current patterns", async () => {
const response = await GET();
expect(response.status).toBe(200);
expect(prisma.correlationPattern.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "owner-1", isCurrent: true },
}),
);
});
it("refuses the list when Insights is disabled", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: apiError('Module "insights" is not enabled', 403),
});
const response = await GET();
expect(response.status).toBe(403);
expect(prisma.correlationPattern.findMany).not.toHaveBeenCalled();
});
});
+45
View File
@@ -0,0 +1,45 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "insights");
if (!gate.enabled) return gate.response;
const patterns = await prisma.correlationPattern.findMany({
where: { userId: user.id, isCurrent: true },
orderBy: [{ lastComputedAt: "desc" }, { canonicalKey: "asc" }],
select: {
id: true,
canonicalKey: true,
family: true,
factorKey: true,
outcomeKey: true,
lagDays: true,
sampleSize: true,
effectSize: true,
pValue: true,
qValue: true,
evidenceHash: true,
lastComputedAt: true,
dismissedAt: true,
},
});
annotate({
action: { name: "insights.pattern.list" },
meta: { total: patterns.length },
});
return apiSuccess({
patterns: patterns.map((pattern) => ({
...pattern,
lastComputedAt: pattern.lastComputedAt.toISOString(),
dismissedAt: pattern.dismissedAt?.toISOString() ?? null,
})),
});
});
+3
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import {
type ResolvedBiomarker,
serialiseLabResult,
@@ -217,6 +218,7 @@ export const PUT = apiHandler(
data,
include: { biomarker: { select: biomarkerSelect } },
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.update", {
userId: user.id,
@@ -250,6 +252,7 @@ export const DELETE = apiHandler(
where: { id },
data: { deletedAt: new Date() },
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.delete", {
userId: user.id,
+16 -1
View File
@@ -9,6 +9,11 @@
*
* `userId` is always narrowed from the session — never a body field. The write
* `data` object is built field-by-field (no mass assignment).
*
* The response carries the resolved `outcome` alongside the two lists. The
* dialog used to raise a green toast on any 200, so a re-scan in which every
* confirmed row turned out to be a duplicate reported "saved 0 readings" under
* a tick. The verdict is the server's to compute; the dialog only renders it.
*/
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
@@ -23,6 +28,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -33,6 +39,7 @@ import {
type InsertedLabForLink,
} from "@/lib/labs/vault-link";
import { annotate } from "@/lib/logging/context";
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
import {
ocrCommitSchema,
type OcrCommitRow,
@@ -185,6 +192,7 @@ async function commitOcrRows(request: NextRequest) {
takenAt: created.takenAt,
});
}
if (inserted.length > 0) invalidateUserHealthScore(user.id);
// S9 — cross-link the freshly inserted labs to the vault document the client
// filed the scanned bytes into. Best-effort: the labs are the authoritative
@@ -235,5 +243,12 @@ async function commitOcrRows(request: NextRequest) {
}
}
return apiSuccess({ inserted, skipped });
return apiSuccess({
inserted,
skipped,
outcome: classifyWrittenOutcome({
written: inserted.length,
skipped: skipped.length,
}),
});
}
+2
View File
@@ -21,6 +21,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -66,6 +67,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null },
});
if (count > 0) invalidateUserHealthScore(user.id);
annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
+2
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -254,6 +255,7 @@ async function postLabResult(request: NextRequest) {
noteEncrypted: note ? encryptNoteToBytes(note) : null,
},
});
invalidateUserHealthScore(user.id);
await auditLog("labResult.create", {
userId: user.id,
@@ -17,6 +17,7 @@
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -338,6 +339,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
externalId: `assessment:${assessment.id}`,
},
});
invalidateUserHealthScore(user.id);
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
@@ -57,18 +57,65 @@ beforeEach(() => {
describe("POST /api/nightscout/sync", () => {
it("syncs the session user and answers with the freshly inserted count", async () => {
vi.mocked(syncUserNightscout).mockResolvedValue(36);
vi.mocked(syncUserNightscout).mockResolvedValue({
imported: 36,
failed: false,
});
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 36 },
data: { imported: 36, failed: false, outcome: "success" },
error: null,
});
expect(syncUserNightscout).toHaveBeenCalledWith("u1");
});
it("does not call a window whose every row was refused a success", async () => {
// The defect: 200 + `imported: 0` rendered as a green tick with a count,
// when in fact not one SGV row reached the database.
vi.mocked(syncUserNightscout).mockResolvedValue({
imported: 0,
failed: true,
});
const response = await post(request());
expect(response.status).toBe(200);
expect((await envelope(response)).data).toEqual({
imported: 0,
failed: true,
outcome: "failed",
});
});
it("calls a window that wrote some rows and lost others a partial", async () => {
vi.mocked(syncUserNightscout).mockResolvedValue({
imported: 30,
failed: true,
});
const response = await post(request());
expect((await envelope(response)).data).toMatchObject({
outcome: "partial",
});
});
it("calls a window that found nothing new empty, not a success", async () => {
vi.mocked(syncUserNightscout).mockResolvedValue({
imported: 0,
failed: false,
});
const response = await post(request());
expect((await envelope(response)).data).toMatchObject({
outcome: "empty",
});
});
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
+6 -3
View File
@@ -3,12 +3,15 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserNightscout } from "@/lib/nightscout/sync";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
/**
* Manually trigger a Nightscout sync for the current user (v1.32.28).
*
* Pulls the recent SGV window from the configured instance; `imported` counts
* the readings that were genuinely new this run, not the ones re-confirmed.
* the readings that were genuinely new this run, not the ones re-confirmed,
* and `outcome` says how that count reads — a window in which every row was
* refused by a constraint imports 0 and must not answer like a quiet night.
*
* No body and no `fullSync` flag: there is no full-history arm to call.
*
@@ -34,8 +37,8 @@ export const POST = apiHandler(async () => {
}
try {
const imported = await syncUserNightscout(user.id);
return apiSuccess({ imported });
const result = await syncUserNightscout(user.id);
return apiSuccess(resolveSyncOutcome(result));
} catch {
return apiError("Nightscout sync failed", 502);
}
+13 -2
View File
@@ -53,19 +53,30 @@ beforeEach(() => {
describe("POST /api/oura/sync", () => {
it("syncs the session user and answers with the imported count", async () => {
vi.mocked(syncUserOura).mockResolvedValue(12);
vi.mocked(syncUserOura).mockResolvedValue({ imported: 12, failed: false });
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 12 },
data: { imported: 12, failed: false, outcome: "success" },
error: null,
});
// The id comes from the session, never from the request.
expect(syncUserOura).toHaveBeenCalledWith("u1");
});
it("does not call a run whose every collection failed a success", async () => {
vi.mocked(syncUserOura).mockResolvedValue({ imported: 0, failed: true });
const response = await post(request());
expect(response.status).toBe(200);
expect((await envelope(response)).data).toMatchObject({
outcome: "failed",
});
});
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
+3 -2
View File
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserOura } from "@/lib/oura/sync";
/**
@@ -31,8 +32,8 @@ export const POST = apiHandler(async () => {
}
try {
const imported = await syncUserOura(user.id);
return apiSuccess({ imported });
const result = await syncUserOura(user.id);
return apiSuccess(resolveSyncOutcome(result));
} catch {
// The failure is already on the `oura` ledger, classified by the provider
// that owns the error shape. Answer generically — an upstream message can
+17 -3
View File
@@ -67,26 +67,40 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
syncUserPolarMock.mockResolvedValue(0);
syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false });
syncUserPolarWorkoutsMock.mockResolvedValue(0);
});
describe("POST /api/polar/sync", () => {
it("runs both Polar legs and totals what they imported", async () => {
syncUserPolarMock.mockResolvedValue(5);
syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false });
syncUserPolarWorkoutsMock.mockResolvedValue(2);
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 7 },
data: { imported: 7, failed: false, outcome: "success" },
error: null,
});
expect(syncUserPolarMock).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1");
});
it("does not call a run whose vitals collections all failed a success", async () => {
syncUserPolarMock.mockResolvedValue({ imported: 0, failed: true });
syncUserPolarWorkoutsMock.mockResolvedValue(0);
const response = await post(request());
expect(response.status).toBe(200);
expect((await envelope(response)).data).toEqual({
imported: 0,
failed: true,
outcome: "failed",
});
});
it("goes through the shared legs entry point, not the provider internals", () => {
const source = readFileSync(join(__dirname, "..", "route.ts"), "utf8");
expect(source).toContain("syncUserPolarLegs");
+3 -2
View File
@@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserPolarLegs } from "@/lib/jobs/reminder/polar-sync";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
/**
* Manually trigger a Polar sync for the current user (v1.32.28).
@@ -31,8 +32,8 @@ export const POST = apiHandler(async () => {
}
try {
const imported = await syncUserPolarLegs(user.id);
return apiSuccess({ imported });
const result = await syncUserPolarLegs(user.id);
return apiSuccess(resolveSyncOutcome(result));
} catch {
// Both legs record their own classified failure on the `polar` ledger.
// Answer generically so no upstream detail rides the response body.
@@ -60,7 +60,7 @@ describe("POST /api/strava/sync", () => {
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 4 },
data: { imported: 4, failed: false, outcome: "success" },
error: null,
});
expect(syncUserStrava).toHaveBeenCalledWith("u1");
+6 -1
View File
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserStrava } from "@/lib/strava/sync";
/**
@@ -28,7 +29,11 @@ export const POST = apiHandler(async () => {
try {
const imported = await syncUserStrava(user.id);
return apiSuccess({ imported });
// `failed: false` is a statement, not a placeholder: the Strava walk has no
// settled-but-partial arm. `upsertStravaWorkouts` rethrows the first row
// error after attempting the batch and the page walk rethrows its own, so
// reaching this line means every fetched activity was written.
return apiSuccess(resolveSyncOutcome({ imported, failed: false }));
} catch {
// Already recorded on the `strava` ledger with its classification. Answer
// generically so no upstream detail rides the response body.
+5 -2
View File
@@ -1,6 +1,7 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserWhoop } from "@/lib/whoop/sync";
import { NextRequest } from "next/server";
@@ -27,6 +28,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
// no body provided -> default incremental sync
}
const { imported } = await syncUserWhoop(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
// Resolved server-side so the card and the native client cannot disagree on
// how the same run reads.
const result = await syncUserWhoop(user.id, { fullSync });
return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
+3 -4
View File
@@ -1,6 +1,7 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { NextRequest } from "next/server";
@@ -24,8 +25,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
// no body provided -> default incremental sync
}
const imported = await syncUserMeasurements(user.id, {
fullSync,
});
return apiSuccess({ imported, fullSync });
const result = await syncUserMeasurements(user.id, { fullSync });
return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
@@ -21,8 +21,7 @@ export default function InsightsBreathingDisturbancesPage() {
i18nPrefix="insights.breathingDisturbances"
explainerMetric="breathingDisturbances"
color="var(--info)"
unit="count"
yAxisUnit="count"
unitKey="insights.units.breathingEvents"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet — what does this metric tell me about my health, and how do I improve it?"
Loaded 100 of 443 files, more files were not shown because too many files have changed in this diff. Show more