diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index cc92b01ad..a49998145 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -35,8 +35,13 @@ jobs: - name: Run Stryker run: pnpm exec stryker run --concurrency 4 + # Without pipefail the step takes the exit status of `tee`, which is + # always 0, so a module under its floor could never fail the run. - name: Check the per-module floors - run: node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY" + shell: bash + run: | + set -o pipefail + node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY" - name: Upload the report if: always() diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml index b33b9b5c8..88c1e0a27 100644 --- a/.github/workflows/test-nightly.yml +++ b/.github/workflows/test-nightly.yml @@ -58,9 +58,12 @@ jobs: echo "FC_SEED=$(( $(date +%s%N) % 2147483647 ))" >> "$GITHUB_ENV" - name: Unit tests, shuffled + shell: bash env: - FC_NUM_RUNS: "500" + # Five times each property's own case count (tests/property/fast-check.ts). + FC_RUNS_FACTOR: "5" run: | + set -o pipefail echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY" pnpm exec vitest run --sequence.shuffle --sequence.seed="${TEST_SEED}" \ --reporter=default --reporter=json --outputFile.json=unit-report.json @@ -104,14 +107,18 @@ jobs: echo "FC_SEED=$(( ${{ github.run_id }} % 2147483647 ))" >> "$GITHUB_ENV" - name: Integration tests, shuffled + shell: bash env: HEALTHLOG_INTEGRATION_WORKERS: "3" - FC_NUM_RUNS: "100" + # Four times each property's own case count; a property's timeout + # grows by the same factor (`propertyTimeout`). + FC_RUNS_FACTOR: "4" # Quoted for the reason given in integration.yml. API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" SESSION_SECRET: "integration-tests" run: | + set -o pipefail echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY" pnpm exec vitest run --config vitest.integration.config.mts \ --shard=${{ matrix.shard }}/4 \ @@ -150,7 +157,9 @@ jobs: path: reports - name: Regenerate + shell: bash run: | + set -o pipefail shopt -s nullglob integration=(reports/nightly-integration-report-*/*.json) unit=(reports/nightly-unit-report/*.json) diff --git a/Dockerfile b/Dockerfile index ecc726d6a..db86834e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,11 @@ RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ # The same for @napi-rs/canvas, whose native binary must match its JS loader. RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \ > /app/.canvas-version +# Every shipped locale's message catalog is present and parses, checked +# against the locale list in the source; the list is written out so the runner +# stage can check the catalogs the image actually carries. +RUN node scripts/verify-message-catalogs.mjs --dir messages \ + --config src/lib/i18n/config.ts --write /app/.message-locales # ── Stage 3: Production runner ───────────────────────────── FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner @@ -111,6 +116,12 @@ COPY --from=builder /app/public ./public # The server reads its message catalogs from here on first use, one locale at # a time (src/lib/i18n/server-catalogs.ts); they are not bundled. COPY --from=builder /app/messages ./messages +# A missing or broken catalog is only noticed when a page asks for that +# locale, so check the copies in this stage against the builder's list. +COPY --from=builder /app/.message-locales /app/scripts/verify-message-catalogs.mjs /tmp/catalog-check/ +RUN node /tmp/catalog-check/verify-message-catalogs.mjs --dir /app/messages \ + --locales /tmp/catalog-check/.message-locales && \ + rm -rf /tmp/catalog-check COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Next's standalone output must carry the externalized worker dependencies. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b19c7de6c..bba317fee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4985,9 +4985,11 @@ paths: - Measurements summary: List workouts (v1.4.32) description: "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user - source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + - Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: - `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`." + source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the + cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per + cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the + list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`, + `activeEnergyKcal`, `avgHr`, `maxHr`." parameters: - in: query name: limit @@ -40860,6 +40862,11 @@ components: category: type: string maxLength: 64 + categoryUpdatedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" deliveryForm: type: string enum: @@ -41136,6 +41143,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - nutrient - amountPerUnit @@ -41490,9 +41500,17 @@ components: - type: string maxLength: 280 - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - startsOn - endsOn @@ -41520,9 +41538,14 @@ components: type: string pattern: ^custom:[0-9a-f-]{36}$ label: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + labelEncrypted: type: string minLength: 1 - maxLength: 40 sortOrder: default: 0 type: integer @@ -41534,9 +41557,11 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - key - - label - sortOrder - isActive additionalProperties: {} @@ -41747,6 +41772,21 @@ components: items: type: string minLength: 1 + linkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false context: anyOf: - type: object @@ -41849,6 +41889,12 @@ components: - type: string minLength: 1 - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 additionalProperties: {} - type: "null" required: @@ -42212,6 +42258,21 @@ components: required: - key additionalProperties: {} + symptomLinkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false required: - date - symptomKeys @@ -42513,6 +42574,11 @@ components: type: - string - "null" + contextEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" aliases: type: array items: @@ -42703,6 +42769,9 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + createdAt: + type: string + minLength: 1 required: - key additionalProperties: {} @@ -43006,12 +43075,22 @@ components: type: - string - "null" + notesEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 + deletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" required: - id - relationship @@ -43369,6 +43448,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - day - nutrient diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 100e9a043..fd0a7d434 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -164,9 +164,11 @@ file needs isolation for a reason the classifier cannot see, put a `fast-check` properties live in `*.property.test.ts` files and take their parameters from `tests/property/fast-check.ts`. Pull requests run every property with a fixed seed, so a run is reproducible on any machine. The -nightly run uses a fresh `FC_SEED` and more cases (`FC_NUM_RUNS`); a failure -prints the seed and the shrunk counterexample, and -`FC_SEED= pnpm test ` replays it. +nightly run uses a fresh `FC_SEED` and multiplies every property's own case +count by `FC_RUNS_FACTOR`; a failure prints the seed and the shrunk +counterexample, and `FC_SEED= pnpm test ` replays it. A property +whose cases are expensive states a small count and passes +`propertyTimeout(baseMs)` as its test timeout, which grows by the same factor. Write a property when the code has a law: an inverse (encrypt and decrypt, a unit and its inverse, a wall clock and its instant), an invariant (a rate diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index c57c738ce..14a790232 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -109,6 +109,32 @@ that for you, and each boot retries). Anything else is a session you can judge: a stuck client may be ended with `SELECT pg_terminate_backend()`. You never need to run `migrate resolve` by hand for this case. +### v1.45.0 notes + +A single-container install (`HEALTHLOG_PROCESS_TYPE=all`, the shipped +compose file) needs no action: the sequence above applies the one migration +and the job-queue upgrade on the first boot. + +- Migration `0471` adds one nullable column to `healthkit_type_syncs`. It is + additive and rewrites no rows. +- The job queue library moves its own schema from version 45 to 46 (new + columns on its queue table, four indexes built concurrently afterwards). + Only a worker process runs that upgrade; a web-only process opens its + send-only connection without migrating and refuses a schema older than + its own (`pg-boss database requires migrations`), so it cannot queue + anything until the upgrade has run. A web/worker split therefore deploys + in this order: stop both roles, start the new **worker** and wait until its + startup log line carries `"reminder_worker": "started"`, then start the new + web containers. + The other order leaves the web containers without a queue (imports and + document reads answer `503 Background worker is not running`) until they + are restarted after the worker. An old web container left running beside + the upgraded schema loses its queue the next time it reconnects. +- The shipped compose file gives the app `stop_grace_period: 45s`, so a + redeploy lets running jobs finish instead of cutting them off after + Docker's default ten seconds. A forked or older compose file should add the + same line to the app and worker services. + ### v1.44.0 notes Migration `0430` drops the `insights_cached_text` column from `users`. A diff --git a/scripts/verify-message-catalogs.mjs b/scripts/verify-message-catalogs.mjs new file mode 100644 index 000000000..0e8dd79b5 --- /dev/null +++ b/scripts/verify-message-catalogs.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Checks that every shipped locale has a message catalog that parses. + * + * The server reads `messages/.json` from disk at run time + * (`src/lib/i18n/server-catalogs.ts`); nothing is bundled, so an image that + * lost the directory, or one file of it, builds and starts clean and only + * shows the gap when a page asks for that locale. The Dockerfile runs this + * twice: in the builder against the locale list in `src/lib/i18n/config.ts`, + * writing that list out, and in the runner stage against the catalogs the + * image actually carries. + * + * node scripts/verify-message-catalogs.mjs --dir messages \ + * --config src/lib/i18n/config.ts [--write .message-locales] + * node verify-message-catalogs.mjs --dir /app/messages \ + * --locales .message-locales + * + * Exits 1 with every problem listed when a catalog is missing, does not parse + * or is not a JSON object, or when the locale list cannot be read. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +function arg(name) { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? null : (process.argv[index + 1] ?? null); +} + +function localesFromConfig(path) { + const source = readFileSync(path, "utf8"); + const block = /export const locales = \[([^\]]*)\]/.exec(source); + if (!block) throw new Error(`no \`export const locales = [...]\` in ${path}`); + const list = [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); + if (list.length === 0) throw new Error(`the locale list in ${path} is empty`); + return list; +} + +function localesFromFile(path) { + const list = readFileSync(path, "utf8").split("\n").filter(Boolean); + if (list.length === 0) throw new Error(`the locale list in ${path} is empty`); + return list; +} + +const dir = arg("dir"); +const config = arg("config"); +const listFile = arg("locales"); +if (!dir || (!config && !listFile)) { + console.error( + "usage: verify-message-catalogs.mjs --dir (--config | --locales ) [--write ]", + ); + process.exit(2); +} + +let locales; +try { + locales = config ? localesFromConfig(config) : localesFromFile(listFile); +} catch (error) { + console.error(`ERROR: ${error.message}`); + process.exit(1); +} + +const problems = []; +for (const locale of locales) { + const path = join(dir, `${locale}.json`); + let text; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + problems.push( + `${path}: ${error.code === "ENOENT" ? "missing" : error.message}`, + ); + continue; + } + try { + const parsed = JSON.parse(text); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + problems.push(`${path}: not a JSON object`); + } + } catch (error) { + problems.push(`${path}: does not parse (${error.message})`); + } +} + +if (problems.length > 0) { + console.error( + "ERROR: message catalogs the server reads at run time are broken:", + ); + for (const problem of problems) console.error(` ${problem}`); + process.exit(1); +} + +const write = arg("write"); +if (write) writeFileSync(write, `${locales.join("\n")}\n`); +console.log(`message catalogs ok: ${locales.join(", ")} in ${dir}`); diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 5a3dc2bef..ec8ebafb7 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -16,7 +16,11 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -176,7 +180,7 @@ describe("the cookie-only helpers cannot see the acting account", () => { const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"]; it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => { - const body = functionBody(read("lib/api-handler.ts"), name); + const body = functionBody(readSource("src/lib/api-handler.ts", name), name); // Non-zero proof, twice over: the slice found a function, and the slice is // the function it claims to be. A renamed helper must fail here rather @@ -190,7 +194,7 @@ describe("the cookie-only helpers cannot see the acting account", () => { }); it("substitutes a data-scope user in exactly two functions", () => { - const src = read("lib/api-handler.ts"); + const src = readSource("src/lib/api-handler.ts", "requireRecordAuth"); const record = functionBody(src, "requireRecordAuth"); expect(record).toContain("readActingCarrier"); @@ -291,7 +295,7 @@ describe("ending a grant and clearing the switch are one act", () => { it("runs the transition and the cleanup in one transaction", () => { const body = privateFunctionBody( - read("lib/sharing/grants.ts"), + readSource("src/lib/sharing/grants.ts", "endAndClear"), "endAndClear", ); // Non-zero proof: a renamed helper fails here rather than asserting @@ -308,7 +312,7 @@ describe("ending a grant and clearing the switch are one act", () => { // claim's own transaction (it takes `tx`), so the guard here is that the // cleanup is part of the same function as the ending. const body = functionBody( - read("lib/sharing/grants.ts"), + readSource("src/lib/sharing/grants.ts", "settleHandoverGuardianGrant"), "settleHandoverGuardianGrant", ); expect(body.length).toBeGreaterThan(0); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index f2336d624..3ff962f76 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 124, - "total": 146508, + "count": 123, + "total": 145551, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -10,7 +10,7 @@ "app/api/measurements/route.ts": 938, "app/api/medications/[id]/route.ts": 1281, "app/api/medications/intake/bulk/route.ts": 1044, - "app/api/workouts/batch/route.ts": 971, + "app/api/workouts/batch/route.ts": 958, "app/medications/page-client.tsx": 839, "app/page-client.tsx": 2324, "components/admin/backups-section.tsx": 1462, @@ -44,7 +44,7 @@ "lib/ai/coach/coach-prose-grounding.ts": 1079, "lib/ai/coach/persistence.ts": 1219, "lib/ai/coach/results/metric-table-tool.ts": 1102, - "lib/ai/coach/snapshot.ts": 1362, + "lib/ai/coach/snapshot.ts": 1347, "lib/ai/coach/stream-events.ts": 817, "lib/ai/coach/tools/executor.ts": 1522, "lib/ai/coach/turn/model.ts": 820, @@ -65,7 +65,7 @@ "lib/daily/today-overview.ts": 838, "lib/dashboard-layout.ts": 971, "lib/dashboard/snapshot.ts": 1550, - "lib/day/records.ts": 1175, + "lib/day/records.ts": 1174, "lib/doctor-report/collect.ts": 803, "lib/export/backup-plan.ts": 853, "lib/export/coach-backup.ts": 1070, @@ -90,9 +90,8 @@ "lib/insights/mood-aggregates.ts": 1055, "lib/insights/mood-status.ts": 814, "lib/insights/status-cache.ts": 812, - "lib/insights/strain-score.ts": 832, "lib/integrations/status.ts": 1445, - "lib/jobs/apple-health-import-worker.ts": 863, + "lib/jobs/apple-health-import-worker.ts": 801, "lib/jobs/backup-restore.ts": 888, "lib/jobs/coach-nudge.ts": 1089, "lib/jobs/insight-pregenerate.ts": 1266, @@ -115,7 +114,7 @@ "lib/medications/scheduling/slot-upsert.ts": 926, "lib/notifications/senders/apns.ts": 1100, "lib/oura/client.ts": 1360, - "lib/oura/sync.ts": 917, + "lib/oura/sync.ts": 902, "lib/polar/client.ts": 1010, "lib/rollups/measurement-rollups.ts": 1496, "lib/search/search-records.ts": 996, @@ -124,7 +123,7 @@ "lib/telegram-webhook-handlers.ts": 1341, "lib/validations/inbound-documents.ts": 1130, "lib/whoop/client.ts": 917, - "lib/withings/sync-sleep.ts": 903, + "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 978, "proxy.ts": 870 } diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 9c388e691..175d5af26 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 411, - "total": 148298, + "total": 148137, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -64,8 +64,8 @@ "app/api/sync/changes/route.ts :: apiHandler(arg)": 427, "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 252, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, - "app/api/workouts/[id]/route.ts :: apiHandler(arg)": 313, - "app/api/workouts/batch/route.ts :: postBatch": 722, + "app/api/workouts/[id]/route.ts :: apiHandler(arg)": 297, + "app/api/workouts/batch/route.ts :: postBatch": 709, "app/auth/login/page.tsx :: LoginPage": 576, "app/auth/register/page.tsx :: RegisterPage": 285, "app/coach/conversations/page.tsx :: CoachConversationsBody": 249, @@ -287,7 +287,7 @@ "lib/ai/anthropic-client.ts :: generateCompletion": 294, "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218, "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, - "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 898, + "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, "lib/ai/coach/turn/model.ts :: runTurnModel": 560, "lib/ai/coach/turn/pipeline.ts :: runTurn": 274, @@ -305,7 +305,7 @@ "lib/api-handler.ts :: apiHandler > wrapped": 201, "lib/auth/profile-update.ts :: applyProfileUpdate": 353, "lib/cache/server-cache.ts :: createServerCaches": 230, - "lib/cycle/backup.ts :: restoreCycleData": 243, + "lib/cycle/backup.ts :: restoreCycleData": 224, "lib/cycle/day-log-write.ts :: writeDayLog": 311, "lib/daily/digest.ts :: buildDailyDigest": 227, "lib/daily/load-digest.ts :: loadDailyDigest": 452, @@ -350,7 +350,7 @@ "lib/insights/weight-status.ts :: prepareWeightStatusForUser": 598, "lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227, "lib/integrations/status.ts :: recordSyncFailure": 292, - "lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 357, + "lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300, "lib/jobs/coach-nudge.ts :: findTriggerForUser": 279, "lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292, "lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222, @@ -409,9 +409,9 @@ "lib/targets/vitals-builder.ts :: buildVitalTargets": 324, "lib/telegram-webhook-handlers.ts :: handleCallback": 599, "lib/telegram-webhook-handlers.ts :: handleTextMessage": 337, - "lib/withings/sync-activity.ts :: syncUserActivity": 391, - "lib/withings/sync-ecg.ts :: syncUserEcg": 213, - "lib/withings/sync-sleep.ts :: syncUserSleep": 429, + "lib/withings/sync-activity.ts :: syncUserActivity": 377, + "lib/withings/sync-ecg.ts :: syncUserEcg": 202, + "lib/withings/sync-sleep.ts :: syncUserSleep": 414, "lib/withings/sync.ts :: syncUserMeasurements": 373, "proxy.ts :: proxy": 499 } diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index ede0dd3fb..e852565a5 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 524, - "total": 5496, + "total": 5495, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -253,7 +253,7 @@ "app/api/withings/credentials/__tests__/route.test.ts": 4, "app/api/withings/webhook/__tests__/route.test.ts": 26, "app/api/withings/webhook/[token]/__tests__/route.test.ts": 8, - "app/api/workouts/__tests__/batch-create.test.ts": 14, + "app/api/workouts/__tests__/batch-create.test.ts": 13, "app/api/workouts/__tests__/canonical-dedup.test.ts": 20, "app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4, "app/c/[token]/__tests__/report-download.test.ts": 11, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 27b89b3ca..2c8a732b0 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2954, - "total": 10508, + "total": 10504, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1706,7 +1706,7 @@ "lib/ai/coach/snapshot-cache.ts": 2, "lib/ai/coach/snapshot-prelude.ts": 17, "lib/ai/coach/snapshot-series.ts": 5, - "lib/ai/coach/snapshot.ts": 42, + "lib/ai/coach/snapshot.ts": 41, "lib/ai/coach/source-measurement-types.ts": 1, "lib/ai/coach/suggest-action.ts": 1, "lib/ai/coach/suggest-gate.ts": 1, @@ -2341,7 +2341,7 @@ "lib/jobs/__tests__/whoop-resource-sync.test.ts": 1, "lib/jobs/__tests__/withings-queues.test.ts": 2, "lib/jobs/__tests__/workout-insight-queue.test.ts": 1, - "lib/jobs/apple-health-import-worker.ts": 5, + "lib/jobs/apple-health-import-worker.ts": 4, "lib/jobs/coach-message-cleanup.ts": 1, "lib/jobs/coach-nudge-ai.ts": 3, "lib/jobs/coach-nudge.ts": 17, @@ -2948,9 +2948,9 @@ "lib/withings/client.ts": 5, "lib/withings/oauth-state.ts": 1, "lib/withings/response-classifier.ts": 1, - "lib/withings/sync-activity.ts": 8, + "lib/withings/sync-activity.ts": 7, "lib/withings/sync-ecg.ts": 4, - "lib/withings/sync-sleep.ts": 10, + "lib/withings/sync-sleep.ts": 9, "lib/withings/sync.ts": 9, "lib/withings/webhook-handler.ts": 3, "lib/workouts/list-read.ts": 1, diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 21ab2d10a..8f38b2dc1 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -15,7 +15,11 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -276,7 +280,10 @@ describe("T1 — the Bearer resolution set is frozen", () => { }); it("the resolver exposes exactly one authorisation arm", () => { - const src = read("lib/auth/bearer.ts"); + const src = readSource( + "src/lib/auth/bearer.ts", + 'permissions.includes("*")', + ); // One wildcard escape hatch, guarding one deny block. If a second // `permissions.includes("*")` short-circuit appears, the arm has forked. const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? []; @@ -375,7 +382,10 @@ describe("T3 — the mint sites are frozen", () => { // does not contradict this: what was retired is a mint at THIS path that // named no scope and answered for the whole API. A child path minting one // named scope, listed in `MINT_SITES` above, is the shape that replaced it. - const tokensRoute = read("app/api/tokens/route.ts"); + const tokensRoute = readSource( + "src/app/api/tokens/route.ts", + "export const GET", + ); expect(tokensRoute).not.toMatch(/export const POST/); expect(tokensRoute).toMatch(/export const GET/); }); @@ -442,12 +452,18 @@ describe("T4b — `documents:write` opens exactly two doors", () => { ]); // Within the upload file, once: the GET list beside the POST must not // name it. - const upload = read("app/api/documents/inbound/route.ts"); + const upload = readSource( + "src/app/api/documents/inbound/route.ts", + "DOCUMENTS_WRITE_SCOPE", + ); expect( requireAuthScopes(upload).filter((x) => x === "DOCUMENTS_WRITE_SCOPE"), ).toHaveLength(1); // And the lookup is a read of one key and nothing else: a single GET. - const lookup = read("app/api/documents/inbound/source/route.ts"); + const lookup = readSource( + "src/app/api/documents/inbound/source/route.ts", + "export const GET", + ); expect( [...lookup.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map( (m) => m[1], @@ -469,7 +485,10 @@ describe("T4c — `workouts:write` opens exactly one door", () => { ); }); expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]); - const batch = read("app/api/workouts/batch/route.ts"); + const batch = readSource( + "src/app/api/workouts/batch/route.ts", + "WORKOUTS_WRITE_SCOPE", + ); expect( requireAuthScopes(batch).filter((x) => x === "WORKOUTS_WRITE_SCOPE"), ).toHaveLength(1); @@ -524,7 +543,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => { // it and every other leg in this file stays green while a single-purpose // token gains delegated write — so the refusal is asserted where it lives, // not only through the behaviour that depends on it. - const src = read("lib/api-handler.ts"); + const src = readSource("src/lib/api-handler.ts", "narrow_scope_selector"); expect(src).toMatch(/narrow_scope_selector/); expect(src).toMatch(/isScopedCredential\(auth\)/); }); diff --git a/src/__tests__/correlation-single-implementation-guard.test.ts b/src/__tests__/correlation-single-implementation-guard.test.ts index c796e97d6..6a6b1d808 100644 --- a/src/__tests__/correlation-single-implementation-guard.test.ts +++ b/src/__tests__/correlation-single-implementation-guard.test.ts @@ -21,6 +21,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { + readSource, readSourceText, stripComments, walkSourceFiles, @@ -59,8 +60,14 @@ describe("one Pearson correlation", () => { }); it("both homes still hold their function", () => { - const engine = readSourceText(join(SRC, "lib/insights/correlations.ts")); - const wrapper = readSourceText(join(SRC, "lib/analytics/correlations.ts")); + const engine = readSource( + "src/lib/insights/correlations.ts", + "export function pearson", + ); + const wrapper = readSource( + "src/lib/analytics/correlations.ts", + "export function significantPearsonCorrelation", + ); expect(engine).toMatch(/export function pearson\s*\(/); expect(wrapper).toMatch( /export function significantPearsonCorrelation\s*\(/, diff --git a/src/__tests__/cycle-verdict-single-decider-guard.test.ts b/src/__tests__/cycle-verdict-single-decider-guard.test.ts index 48a19d200..fe0d9101e 100644 --- a/src/__tests__/cycle-verdict-single-decider-guard.test.ts +++ b/src/__tests__/cycle-verdict-single-decider-guard.test.ts @@ -24,7 +24,7 @@ import { mayBeCycleContext, parse, } from "./helpers/cycle-verdict-derivation"; -import { readSourceText } from "./helpers/source-files"; +import { readSource, readSourceText } from "./helpers/source-files"; const ROOT = process.cwd(); @@ -110,19 +110,21 @@ describe("the surfaces that show the verdict actually read it", () => { // The BBT chart used to walk the calendar itself to find the cycle start, // which was a second copy of the same walk. It now takes the resolved // value as a prop, and the cycle page feeds it from the verdict. - const chart = readSourceText( - join(ROOT, "src/components/cycle/bbt-chart.tsx"), + const chart = readSource( + "src/components/cycle/bbt-chart.tsx", + "cycleStartDate", ); expect(chart).toContain("cycleStartDate"); - const paths = accessPaths(parseFile("src/components/cycle/cycle-view.tsx")); + const view = "src/components/cycle/cycle-view.tsx"; + const paths = accessPaths(parse(view, readSource(view, "verdict"))); expect([...paths]).toContain("verdict.cycleStartDate"); }); it("the calendar response type declares the verdict as always present", () => { // An optional verdict is a plausible default that lets a client fall back // to deriving its own. - const types = readSourceText(join(ROOT, "src/components/cycle/types.ts")); + const types = readSource("src/components/cycle/types.ts", "CycleVerdict"); expect(types).toContain("verdict: CycleVerdict;"); expect(types).not.toContain("verdict?:"); }); diff --git a/src/__tests__/external-id-stability-guard.test.ts b/src/__tests__/external-id-stability-guard.test.ts index 09b03fc17..3d3d27425 100644 --- a/src/__tests__/external-id-stability-guard.test.ts +++ b/src/__tests__/external-id-stability-guard.test.ts @@ -24,7 +24,11 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); const VALIDATOR_MODULE = "@/lib/validations/external-id"; @@ -280,9 +284,9 @@ describe("identities derived outside a Zod schema stay inside the floor", () => const b = body("lib/import/csv-measurements.ts"); expect(b).toContain("isStableExternalId"); expect(b).toContain('skip("unstable_external_id")'); - expect(read("lib/import/csv-measurements.ts")).toContain( - '| "unstable_external_id"', - ); + expect( + readSource("src/lib/import/csv-measurements.ts", "unstable_external_id"), + ).toContain('| "unstable_external_id"'); }); it("the MCP write path checks the key BEFORE it is hashed into an identity", () => { diff --git a/src/__tests__/fast-check-runs-factor.test.ts b/src/__tests__/fast-check-runs-factor.test.ts new file mode 100644 index 000000000..a9fa55ed2 --- /dev/null +++ b/src/__tests__/fast-check-runs-factor.test.ts @@ -0,0 +1,39 @@ +/** + * The nightly property runs multiply each property's own case count instead + * of replacing it. A nightly count of 100 for every property ran the backup + * round trip, which states twelve cases for its cost, eight times over + * against a timeout sized for twelve. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadWith(factor: string | undefined) { + vi.resetModules(); + if (factor === undefined) vi.stubEnv("FC_RUNS_FACTOR", ""); + else vi.stubEnv("FC_RUNS_FACTOR", factor); + return import("../../tests/property/fast-check"); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("FC_RUNS_FACTOR", () => { + it("keeps each property's own count on a pull request", async () => { + const { propertyParameters, propertyTimeout } = await loadWith(undefined); + expect(propertyParameters(12).numRuns).toBe(12); + expect(propertyParameters().numRuns).toBe(100); + expect(propertyTimeout(60_000)).toBe(60_000); + }); + + it("multiplies each property's count and the timeout it was given", async () => { + const { propertyParameters, propertyTimeout } = await loadWith("4"); + expect(propertyParameters(12).numRuns).toBe(48); + expect(propertyParameters(300).numRuns).toBe(1200); + expect(propertyTimeout(60_000)).toBe(240_000); + }); + + it("ignores a value that is not a positive number", async () => { + const { propertyParameters } = await loadWith("-3"); + expect(propertyParameters(12).numRuns).toBe(12); + }); +}); diff --git a/src/__tests__/linear-fit-single-implementation-guard.test.ts b/src/__tests__/linear-fit-single-implementation-guard.test.ts index ca5b7888a..2f149383e 100644 --- a/src/__tests__/linear-fit-single-implementation-guard.test.ts +++ b/src/__tests__/linear-fit-single-implementation-guard.test.ts @@ -10,7 +10,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -65,7 +69,12 @@ describe("one least-squares line", () => { // The implementation itself matches, so the sweep read real files. expect( HAND_ROLLED_SLOPE.test( - stripComments(readSourceText(join(SRC, "lib/stats/linear-fit.ts"))), + stripComments( + readSource( + "src/lib/stats/linear-fit.ts", + "export function linearFit", + ), + ), ), ).toBe(true); }); diff --git a/src/__tests__/memory-block-egress-guard.test.ts b/src/__tests__/memory-block-egress-guard.test.ts index 7c0fa8bd7..22ae40339 100644 --- a/src/__tests__/memory-block-egress-guard.test.ts +++ b/src/__tests__/memory-block-egress-guard.test.ts @@ -22,7 +22,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); const MEMORY = "lib/ai/coach/memory"; @@ -59,18 +63,21 @@ function checksBeforeRead(body: string, check: RegExp): boolean { describe("memory block egress guard", () => { const block = functionBody( - read(`${MEMORY}/context-block.ts`), + readSource(`src/${MEMORY}/context-block.ts`, "buildMemoryContextBlock"), "buildMemoryContextBlock", ); const lines = functionBody( - read(`${MEMORY}/plan-progress.ts`), + readSource(`src/${MEMORY}/plan-progress.ts`, "buildPlanProgressLines"), "buildPlanProgressLines", ); it("the memory block checks egress before it reads anything", () => { expect(block.length).toBeGreaterThan(200); expect(checksBeforeRead(block, /egressAllowed\(/)).toBe(true); - const helper = read(`${MEMORY}/context-block.ts`); + const helper = readSource( + `src/${MEMORY}/context-block.ts`, + "aiEgressRefusal", + ); expect(helper).toMatch(/aiEgressRefusal\(\s*"coach"/); expect(helper).toMatch(/aiCapabilityForRecord\(args\.userId, "coach"\)/); // Broken on purpose: the check moved below the reads. diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts index 050953e4f..041112000 100644 --- a/src/__tests__/mood-backup-completeness.test.ts +++ b/src/__tests__/mood-backup-completeness.test.ts @@ -55,18 +55,12 @@ const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record = { moodEntryId: "re-bound to the restored parent row's id; the context rides inside its entry", userId: "user-scoped backup; owner is re-derived from the restoring account", - createdAt: - "the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed", - updatedAt: - "the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value", }; const EXCLUDED_TAG_LINK_COLUMNS: Record = { moodEntryId: "re-bound to the restored parent row's id, never carried", moodTagId: "the link travels by the tag's key; a source-instance id need not exist in the target catalogue", - createdAt: - "the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one", }; function modelBlock(schema: string, model: string): string { diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 9e1838b78..4b0acd796 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -16,7 +16,11 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -181,7 +185,10 @@ describe("the recent-proof gate", () => { }); it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => { - const src = read("lib/api-handler.ts"); + const src = readSource( + "src/lib/api-handler.ts", + "export async function assertRecentCookieProof(", + ); const start = src.indexOf("export async function assertRecentCookieProof("); expect(start).toBeGreaterThan(-1); const body = src.slice(start, src.indexOf("\nexport ", start + 1)); diff --git a/src/__tests__/record-session-fence-guard.test.ts b/src/__tests__/record-session-fence-guard.test.ts index e6cacd6a0..eed7928bc 100644 --- a/src/__tests__/record-session-fence-guard.test.ts +++ b/src/__tests__/record-session-fence-guard.test.ts @@ -41,7 +41,11 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -80,7 +84,10 @@ function functionBody(src: string, name: string): string { return ""; } -const API_HANDLER = read("lib/api-handler.ts"); +const API_HANDLER = readSource("src/lib/api-handler.ts", [ + "requireRecordAuth", + "requireGuardianAuth", +]); /** The two helpers that resolve a record and therefore must consult the fence. */ const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const; @@ -180,8 +187,11 @@ describe("the fence runs in exactly two auth helpers", () => { // client's context is unknown; the switch endpoint is the way out of a // context that cannot be proved. Fencing either would make an unprovable // context unrecoverable. - const me = read("app/api/auth/me/route.ts"); - const sw = read("app/api/account/switch/route.ts"); + const me = readSource("src/app/api/auth/me/route.ts", "requireActorAuth"); + const sw = readSource( + "src/app/api/account/switch/route.ts", + "requireActorAuth", + ); expect(me).toContain("requireActorAuth"); expect(sw).toContain("requireActorAuth"); expect(me).not.toContain("assertRecordSessionFence"); @@ -228,7 +238,10 @@ describe("the epoch has one writer and a closed reader set", () => { }); it("is consulted by the idempotency wrapper, above the cache", () => { - const src = read("lib/idempotency.ts"); + const src = readSource( + "src/lib/idempotency.ts", + "refuseStaleRecordSessionClaim(claim)", + ); const fence = src.indexOf("refuseStaleRecordSessionClaim(claim)"); const cache = src.indexOf("findCached(ctx)"); const claimRow = src.indexOf("claimKey(ctx)"); diff --git a/src/__tests__/spread-single-implementation-guard.test.ts b/src/__tests__/spread-single-implementation-guard.test.ts index 815f3cb9c..434e49f7d 100644 --- a/src/__tests__/spread-single-implementation-guard.test.ts +++ b/src/__tests__/spread-single-implementation-guard.test.ts @@ -20,6 +20,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { + readSource, readSourceText, stripComments, walkSourceFiles, @@ -87,10 +88,17 @@ describe("one standard deviation", () => { }); it("the series API and the AI and MCP payload report the same spread", () => { - expect(read("app/api/measurements/series/route.ts")).toMatch( - /standardDeviation\(\s*values,\s*READING_SPREAD\s*\)/, + expect( + stripComments( + readSource( + "src/app/api/measurements/series/route.ts", + "READING_SPREAD", + ), + ), + ).toMatch(/standardDeviation\(\s*values,\s*READING_SPREAD\s*\)/); + const features = stripComments( + readSource("src/lib/insights/features.ts", "READING_SPREAD"), ); - const features = read("lib/insights/features.ts"); expect( features.match(/roundedSpread\(\s*vals,\s*READING_SPREAD\s*\)/g), ).toHaveLength(2); diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts index 096f85055..ce1f2e423 100644 --- a/src/__tests__/sql-now-utc-guard.test.ts +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -16,7 +16,7 @@ import { readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { describe, expect, it } from "vitest"; -import { readSourceText } from "./helpers/source-files"; +import { readSource, readSourceText } from "./helpers/source-files"; const ROOT = join(__dirname, "..", ".."); const SRC = join(ROOT, "src"); @@ -85,7 +85,7 @@ function bareReads(): string[] { describe("raw SQL clock reads are anchored to UTC", () => { it("finds the anchored form across the swept files", () => { // The matcher must see something, or a broken walk would pass. - const passkey = readSourceText(join(SRC, "lib/auth/passkey.ts")); + const passkey = readSource("src/lib/auth/passkey.ts", ANCHORED); expect(passkey.match(ANCHORED)?.length).toBeGreaterThanOrEqual(3); expect(SQL_CLOCK.test("AND expires_at > NOW()")).toBe(true); expect(SQL_CLOCK.test("updated_at = now()")).toBe(true); diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts index e295dba3b..250f9bb83 100644 --- a/src/__tests__/step-up-elevation-guard.test.ts +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -25,7 +25,11 @@ import { describe, it, expect } from "vitest"; import { join } from "node:path"; -import { readSourceText, walkSourceFiles } from "./helpers/source-files"; +import { + readSource, + readSourceText, + walkSourceFiles, +} from "./helpers/source-files"; const SRC = join(process.cwd(), "src"); @@ -256,7 +260,10 @@ describe("S6 — the erasure routes accept a fresh-factor elevation", () => { // A password-proved elevation must not erase an account, and the gate // must not grow a second, inline factor check beside the elevation: one // mint surface, one redemption path. - const src = read("lib/api-handler.ts"); + const src = readSource( + "src/lib/api-handler.ts", + "export async function requireFreshMfaOrElevationIfEnrolled(", + ); const start = src.indexOf( "export async function requireFreshMfaOrElevationIfEnrolled(", ); @@ -307,7 +314,10 @@ describe("S2 — mint and redeem happen in exactly one place each", () => { describe("S3 — requireAdmin stays cookie-only", () => { it("resolves through getSession and nothing else", () => { - const src = read("lib/api-handler.ts"); + const src = readSource("src/lib/api-handler.ts", [ + "export async function requireAdmin(", + "export async function requireCookieAuth(", + ]); const body = src.slice( src.indexOf("export async function requireAdmin("), src.indexOf("export async function requireCookieAuth("), @@ -335,7 +345,10 @@ describe("S4 — the mint surface refuses a cookie", () => { }); it("requireBearerAuth never consults the session cookie", () => { - const src = read("lib/api-handler.ts"); + const src = readSource("src/lib/api-handler.ts", [ + "export async function requireBearerAuth(", + "export const STEP_UP_ELEVATION_HEADER", + ]); const body = src.slice( src.indexOf("export async function requireBearerAuth("), src.indexOf("export const STEP_UP_ELEVATION_HEADER"), @@ -372,7 +385,10 @@ describe("S5 — a password proof cannot satisfy a fresh-factor gate", () => { }); it("the claim re-checks the factor rather than trusting the validation", () => { - const src = read("lib/auth/step-up.ts"); + const src = readSource( + "src/lib/auth/step-up.ts", + "export async function claimStepUpElevation(", + ); const claim = src.slice( src.indexOf("export async function claimStepUpElevation("), ); diff --git a/src/__tests__/test-realism-ratchet.test.ts b/src/__tests__/test-realism-ratchet.test.ts index e35e78a9c..9fbd4c0dc 100644 --- a/src/__tests__/test-realism-ratchet.test.ts +++ b/src/__tests__/test-realism-ratchet.test.ts @@ -19,6 +19,9 @@ * assertion then passes for the wrong reason. New ones go through * `readSource(path, anchors)` or `readSourceFolder(dir, { floor })` in * `helpers/source-files.ts`, which this count does not include. + * `readSourceText` is the unanchored reader the sweeps use; called with a + * fixed source path, directly or through a local wrapper that is, it is + * the same read without the anchors, and it counts. * * A new offender fails; one that disappears (or loses casts) fails until * the lower number is written back with @@ -29,7 +32,10 @@ * the Prisma client entry itself, or a spy on every delegate, is not a * pattern in use and is not matched. The source-read check is a text * heuristic: a file that calls `readFileSync`/`readFile` and names a source - * path (a literal ending in `.ts`, `.tsx`, `.js` or `.mjs`). + * path (a literal ending in `.ts`, `.tsx`, `.js` or `.mjs`), or that calls + * `readSourceText` with such a literal, or calls a local one-parameter wrapper + * of `readSourceText` with one. A wrapper fed from a list of paths is not + * matched. */ import { describe, expect, it } from "vitest"; import ts from "typescript"; @@ -45,6 +51,26 @@ const DB_MOCK = const HANDLER_STUB = /vi\.(?:do)?[mM]ock\(\s*["'`]@\/lib\/api-handler["'`]/; const READS_FILE = /\b(?:readFileSync|readFile)\s*\(/; const SOURCE_PATH = /["'`][^"'`\n]*\.(?:tsx?|mjs|js)["'`]/; +/** `readSourceText(...)` whose argument names a source path literal. */ +const READ_SOURCE_TEXT_FIXED = new RegExp( + String.raw`\breadSourceText\(\s*(?:join\([^)]*?)?` + SOURCE_PATH.source, +); +/** A local wrapper whose one parameter becomes the `readSourceText` path. */ +const READ_SOURCE_TEXT_WRAPPER = + /(?:function\s+(\w+)|(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?)\s*\(\s*(\w+)\s*(?::\s*string)?[^)]*\)[^{=]*(?:=>|\{)[\s\S]{0,160}?readSourceText\(\s*(?:join\([^)]*?\b\3\b|\3\b)/g; + +/** Whether a test reads a fixed source path through `readSourceText`. */ +function readsSourceTextByPath(text: string): boolean { + if (!text.includes("readSourceText")) return false; + if (READ_SOURCE_TEXT_FIXED.test(text)) return true; + for (const m of text.matchAll(READ_SOURCE_TEXT_WRAPPER)) { + const name = m[1] ?? m[2]; + if (new RegExp(`\\b${name}\\(\\s*` + SOURCE_PATH.source).test(text)) { + return true; + } + } + return false; +} function asNeverCount(rel: string, text: string): number { if (!text.includes("as never")) return 0; @@ -68,7 +94,9 @@ function classifyTest(rel: string, text: string) { dbMock: routeTest && DB_MOCK.test(text), handlerStub: routeTest && HANDLER_STUB.test(text), asNever: asNeverCount(rel, text), - sourceRead: READS_FILE.test(text) && SOURCE_PATH.test(text), + sourceRead: + (READS_FILE.test(text) && SOURCE_PATH.test(text)) || + readsSourceTextByPath(text), }; } @@ -143,6 +171,8 @@ describe("test realism ratchet", () => { }); const READ_CALL = ["read", "File", "Sync"].join(""); +// Split for the same reason: this file must not match its own pattern. +const READ_TEXT = ["read", "Source", "Text"].join(""); describe("the classifier", () => { it("sees the shapes it counts", () => { @@ -182,6 +212,32 @@ describe("the classifier", () => { }); }); + it("counts a fixed path read through the unanchored reader", () => { + const direct = `const src = ${READ_TEXT}(join(SRC, "lib/x.ts"));`; + const wrapped = [ + `function read(rel: string): string {`, + ` return ${READ_TEXT}(join(SRC, rel));`, + `}`, + `const src = read("lib/api-handler.ts");`, + ].join("\n"); + expect(classifyTest("__tests__/a.test.ts", direct).sourceRead).toBe(true); + expect(classifyTest("__tests__/a.test.ts", wrapped).sourceRead).toBe(true); + }); + + it("leaves an anchored read and a sweep through the reader alone", () => { + const anchored = 'const src = readSource("src/lib/x.ts", "anchor");'; + const sweep = [ + `function read(rel: string): string {`, + ` return ${READ_TEXT}(join(SRC, rel));`, + `}`, + `for (const rel of walkSourceFiles(SRC, { floor: 10 })) read(rel);`, + ].join("\n"); + expect(classifyTest("__tests__/a.test.ts", anchored).sourceRead).toBe( + false, + ); + expect(classifyTest("__tests__/a.test.ts", sweep).sourceRead).toBe(false); + }); + it("counts a relative database mock and only under app/api", () => { expect( classifyTest( diff --git a/src/__tests__/verify-message-catalogs-script.test.ts b/src/__tests__/verify-message-catalogs-script.test.ts new file mode 100644 index 000000000..06628fb27 --- /dev/null +++ b/src/__tests__/verify-message-catalogs-script.test.ts @@ -0,0 +1,63 @@ +/** + * `scripts/verify-message-catalogs.mjs`, which the image build runs in the + * builder and again in the runner stage, passes the shipped catalogs and fails + * on a missing or corrupt one. A check that cannot fail would let an image + * without its catalogs through, so both directions are pinned. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +const SCRIPT = join(process.cwd(), "scripts", "verify-message-catalogs.mjs"); +const CONFIG = join(process.cwd(), "src", "lib", "i18n", "config.ts"); + +function run(args: string[]) { + return spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" }); +} + +let scratch: string | null = null; +afterEach(() => { + if (scratch) rmSync(scratch, { recursive: true, force: true }); + scratch = null; +}); + +describe("verify-message-catalogs", () => { + it("passes the catalogs in the tree and writes the locale list", () => { + scratch = mkdtempSync(join(tmpdir(), "hl-catalog-check-")); + const list = join(scratch, "locales"); + const res = run([ + "--dir", + join(process.cwd(), "messages"), + "--config", + CONFIG, + "--write", + list, + ]); + expect(res.status).toBe(0); + expect(res.stdout).toMatch(/message catalogs ok: .*\ben\b/); + + // The runner-stage form, against the list the builder wrote. + expect( + run(["--dir", join(process.cwd(), "messages"), "--locales", list]).status, + ).toBe(0); + }); + + it("fails on a missing catalog and on one that does not parse", () => { + scratch = mkdtempSync(join(tmpdir(), "hl-catalog-check-")); + const dir = join(scratch, "messages"); + mkdirSync(dir); + writeFileSync(join(dir, "en.json"), "{}"); + writeFileSync(join(dir, "de.json"), '{"common": '); + const list = join(scratch, "locales"); + writeFileSync(list, "en\nde\nfr\n"); + + const res = run(["--dir", dir, "--locales", list]); + expect(res.status).toBe(1); + expect(res.stderr).toMatch(/de\.json: does not parse/); + expect(res.stderr).toMatch(/fr\.json: missing/); + expect(res.stderr).not.toMatch(/en\.json/); + }); +}); diff --git a/src/__tests__/workflow-pipefail-guard.test.ts b/src/__tests__/workflow-pipefail-guard.test.ts new file mode 100644 index 000000000..a7fa32b27 --- /dev/null +++ b/src/__tests__/workflow-pipefail-guard.test.ts @@ -0,0 +1,96 @@ +/** + * A workflow step that pipes a command into `tee` runs with pipefail. + * + * GitHub runs a `run:` script with `bash -e {0}` unless the step names a + * shell, and without pipefail a pipeline's exit status is its last command's. + * `tee` always succeeds, so `node scripts/mutation-floors.mjs | tee -a + * "$GITHUB_STEP_SUMMARY"` passed whatever the floors said. A step that names + * `shell: bash` runs as `bash --noprofile --norc -eo pipefail {0}`; this guard + * asks for that and for `set -o pipefail` in the script as well, so the + * intent is visible where the pipe is. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +const WORKFLOWS = join(process.cwd(), ".github", "workflows"); + +interface Step { + name?: string; + run?: string; + shell?: string; +} + +/** Whether a script pipes something into `tee` on a line that is not a comment. */ +function pipesIntoTee(script: string): boolean { + return script + .split("\n") + .some((line) => !/^\s*#/.test(line) && /[^|]\|\s*tee\b/.test(line)); +} + +/** The steps of a workflow that pipe into `tee` without pipefail. */ +function offenders(file: string, text: string): string[] { + const doc = YAML.parse(text) as { + defaults?: { run?: { shell?: string } }; + jobs?: Record< + string, + { defaults?: { run?: { shell?: string } }; steps?: Step[] } + >; + }; + const out: string[] = []; + for (const [jobName, job] of Object.entries(doc.jobs ?? {})) { + (job.steps ?? []).forEach((step, index) => { + if (!step.run || !pipesIntoTee(step.run)) return; + const shell = + step.shell ?? job.defaults?.run?.shell ?? doc.defaults?.run?.shell; + const ok = shell === "bash" && /\bset -o pipefail\b/.test(step.run); + if (!ok) out.push(`${file} ${jobName} #${index} ${step.name ?? ""}`); + }); + } + return out; +} + +function workflowFiles(): string[] { + return readdirSync(WORKFLOWS).filter((f) => /\.ya?ml$/.test(f)); +} + +describe("workflow steps that pipe into tee", () => { + it("reads the workflows it claims to check", () => { + expect(workflowFiles().length).toBeGreaterThan(10); + const teeSteps = workflowFiles().filter((f) => + pipesIntoTee(readFileSync(join(WORKFLOWS, f), "utf8")), + ); + expect(teeSteps.length).toBeGreaterThan(0); + }); + + it("run with pipefail", () => { + const found = workflowFiles().flatMap((f) => + offenders(f, readFileSync(join(WORKFLOWS, f), "utf8")), + ); + expect(found).toEqual([]); + }); + + it("catches the shape it guards against", () => { + const bad = [ + "jobs:", + " check:", + " steps:", + " - name: floors", + ' run: node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"', + ].join("\n"); + expect(offenders("bad.yml", bad)).toEqual(["bad.yml check #0 floors"]); + const good = [ + "jobs:", + " check:", + " steps:", + " - name: floors", + " shell: bash", + " run: |", + " set -o pipefail", + ' node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"', + ].join("\n"); + expect(offenders("good.yml", good)).toEqual([]); + }); +}); diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 810a59174..88d05da85 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -31,11 +31,21 @@ const SRC = join(process.cwd(), "src"); const PICKER = "lib/measurements/pick-canonical-workout-rows.ts"; /** - * Every production call site of the picker, frozen. + * The only module that hands rows to the picker: the windowed reader, which + * picks over rows it read with the duplicate-window margin. Every surface goes + * through it, and its output types make a read without the margin a compile + * error. The strain engine's per-cluster impulse sum lives there too and is + * handed the rows of the engine's own windowed read. + */ +const PICKER_CALLERS = ["lib/workouts/canonical-read.ts"]; + +/** + * Every production read that decides which workout is canonical, frozen. * - * Adding a surface that collapses workouts is a deliberate act: it must fetch - * the identity fields or it silently disagrees with every other surface. Adding - * the file here is the acknowledgement. + * Adding a surface that collapses workouts is a deliberate act: it must read + * through `readWorkoutsAroundRange` and fetch the identity fields, or it + * silently disagrees with every other surface. Adding the file here is the + * acknowledgement. The value is the file that owns the read's `select`. */ const IDENTITY_OWNER: Record = { "app/api/workouts/[id]/route.ts": "app/api/workouts/[id]/route.ts", @@ -49,15 +59,15 @@ const IDENTITY_OWNER: Record = { // The activity calendar counts workouts per day; a session two devices // recorded must count once there, as it does in the list. "lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts", - // The only indirection in the set. The Coach block is handed rows it does not - // read itself; the snapshot builder fetches them in parallel with every other - // block's data. Recording the owner here rather than assuming the call site - // owns its own select is the whole point of this map. - "lib/ai/coach/snapshot-blocks/workouts-block.ts": "lib/ai/coach/snapshot.ts", - // The write path: the batch route runs the same picker over the rows it is - // about to insert, so a duplicate inside one batch is dropped by the rule - // the list later applies. - "app/api/workouts/batch/route.ts": "app/api/workouts/batch/route.ts", + // The Coach block is handed rows it does not read itself; the snapshot + // builder fetches them through this read in parallel with every other + // block's data. + "lib/ai/coach/snapshot-blocks/workouts-read.ts": + "lib/ai/coach/snapshot-blocks/workouts-read.ts", + // The write path: the batch route clusters the rows it is about to insert + // together with the stored rows around them, so a duplicate is dropped by + // the rule the list later applies. + "lib/workouts/batch-dedup.ts": "lib/workouts/batch-dedup.ts", // The day-total training impulse, the briefing's workout tally and the day // view's workout section: each counted a session synced by two services // twice while the list showed it once. @@ -72,10 +82,16 @@ const IDENTITY_OWNER: Record = { /** * How a call site proves its rows carry the duration. A read selects the - * column; the batch route maps it off the row it computed for the insert. + * column; the batch route also maps it off the row it computed for the insert. */ -const IDENTITY_PROOF: Record = { - "app/api/workouts/batch/route.ts": "durationSec: p.row.durationSec", +const IDENTITY_PROOF: Record = { + // These two select `WORKOUT_PICK_SELECT`, whose duration is checked below. + "lib/day/records.ts": ["select: WORKOUT_PICK_SELECT"], + "lib/insights/correlation-channel-series.ts": ["select: WORKOUT_PICK_SELECT"], + "lib/workouts/batch-dedup.ts": [ + "durationSec: true", + "durationSec: p.row.durationSec", + ], }; const EXPECTED_CALL_SITES = Object.keys(IDENTITY_OWNER).sort(); @@ -93,17 +109,13 @@ function read(rel: string): string { } /** - * Lines that actually invoke the picker. Comment lines are excluded: a - * docblock that names the picker is documentation rather than a call. + * Lines that actually invoke `call`. Comment lines are excluded: a docblock + * that names the function is documentation rather than a call. */ -function invokesPicker(source: string): boolean { +function invokes(source: string, call: string): boolean { return source .split("\n") - .some( - (line) => - line.includes("pickCanonicalWorkoutRows(") && - !/^\s*(\*|\/\/|\/\*)/.test(line), - ); + .some((line) => line.includes(call) && !/^\s*(\*|\/\/|\/\*)/.test(line)); } describe("the picker's call-site set is frozen", () => { @@ -114,13 +126,29 @@ describe("the picker's call-site set is frozen", () => { expect(sourceFiles().length).toBeGreaterThan(1500); }); - it("has exactly the call sites this file acknowledges", () => { + it("hands rows to the picker only from the windowed reader", () => { const found = sourceFiles() .filter((rel) => rel !== PICKER) - .filter((rel) => invokesPicker(read(rel))); + .filter((rel) => invokes(read(rel), "pickCanonicalWorkoutRows(")); + + expect(found).toEqual(PICKER_CALLERS); + }); + + it("has exactly the windowed reads this file acknowledges", () => { + const found = sourceFiles() + .filter((rel) => rel !== "lib/workouts/canonical-read.ts") + .filter((rel) => invokes(read(rel), "readWorkoutsAroundRange(")); expect(found).toEqual(EXPECTED_CALL_SITES); }); + + it("scores strain only on the sessions of the windowed range", () => { + // The strain engine sums impulses over the rows of its windowed read; it + // must keep the sessions its range owns, not every cluster the margin read. + expect(read("lib/insights/strain-score.ts")).toContain( + "(at) => startsInRange(workouts.range, at)", + ); + }); }); describe("every call site fetches the session identity", () => { @@ -130,9 +158,20 @@ describe("every call site fetches the session identity", () => { // rows came from. Coarse in the safe direction — it can only fail to catch // a second unrelated select in the same file, never pass a caller that // fetches no duration at all. - expect(read(IDENTITY_OWNER[rel])).toContain( - IDENTITY_PROOF[rel] ?? "durationSec: true", + for (const proof of IDENTITY_PROOF[rel] ?? ["durationSec: true"]) { + expect(read(IDENTITY_OWNER[rel])).toContain(proof); + } + }); +}); + +describe("the shared pick select carries the identity", () => { + it("selects durationSec", () => { + const reader = read("lib/workouts/canonical-read.ts"); + const select = reader.slice( + reader.indexOf("export const WORKOUT_PICK_SELECT"), + reader.indexOf("} as const;"), ); + expect(select).toContain("durationSec: true"); }); }); diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 428c03c59..274abaefd 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -6,9 +6,9 @@ * canonical envelope per workout. The endpoint: * * - Returns the canonical row when the requested id wins its - * `(startedAt slot, sportType)` cluster against the user's - * source-priority ladder. The picker reuses - * `pickCanonicalWorkoutRows()` from v1.4.30 so the dedup contract + * duplicate cluster against the user's source-priority ladder. The + * cluster is resolved through the same windowed read the list uses + * (`src/lib/workouts/canonical-read.ts`), so the dedup contract * stays in sync with the list endpoint. * - When the requested id is a non-canonical twin (e.g. the user * opened a deep-link to a Withings row but the cluster's winner is @@ -40,7 +40,14 @@ import { lockPersonalRecordsForUser, workoutPrSlotsForSport, } from "@/lib/personal-records/pr-detection-worker"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalClusterOf, + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + type CanonicalReadRow, + type WorkoutBoundedRead, + type WorkoutStartBounds, +} from "@/lib/workouts/canonical-read"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; @@ -60,6 +67,38 @@ import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; +/** How many earlier rows one step of the previous-session search reads. */ +const PREVIOUS_SESSION_BATCH = 20; +/** Steps before the search gives up; each reaches past the last one. */ +const PREVIOUS_SESSION_MAX_STEPS = 5; + +/** + * The newest canonical workout of the sport that starts before `before` (the + * first start of the current session's cluster). A batch of earlier rows names + * the span to resolve; the span is read through the windowed reader so its + * twins are resolved the way the list resolves them. A span whose rows are all + * hidden by a twin further back moves the search back one batch. + */ +async function previousCanonicalWorkoutId( + read: WorkoutBoundedRead, + latestStartsBefore: (to: Date, take: number) => Promise, + before: Date, + priorityJson: unknown, +): Promise { + let to = before; + for (let step = 0; step < PREVIOUS_SESSION_MAX_STEPS; step += 1) { + const earlier = await latestStartsBefore(to, PREVIOUS_SESSION_BATCH); + if (earlier.length === 0) return null; + const from = earlier[earlier.length - 1]!; + const windowed = await readWorkoutsAroundRange({ from, to: before }, read); + const canonical = canonicalWorkoutsInRange(windowed, priorityJson); + const newest = canonical[canonical.length - 1]; + if (newest) return newest.id; + to = from; + } + return null; +} + export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -113,38 +152,38 @@ export const GET = apiHandler( return apiError("Workout not found", 404); } - // Resolve the canonical winner for the row's cluster. The picker - // reads every workout in the same 5-minute slot and sport-type - // bucket; in practice that's ≤ 4 rows (Apple + Withings + the two - // legacy sources). The query is bounded so the round-trip is cheap. - const slotMs = 5 * 60 * 1000; - const slotStart = new Date( - Math.floor(row.startedAt.getTime() / slotMs) * slotMs, + // Resolve the canonical winner for the row's cluster. The read goes + // through the same windowed reader as the list, so the cluster drawn here + // is the one the list draws: a twin that straddles a clock slot boundary, + // or one whose cluster begins with an earlier workout, resolves to the row + // the list shows. + const readSameSport = (bounds: WorkoutStartBounds) => + prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: bounds, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + // Part of the canonical session identity consulted by the picker — + // without it a HealthKit re-send of this very workout stays in the + // cluster and the deep link can resolve to the duplicate instead of + // the row the list shows. + durationSec: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + }); + const around = await readWorkoutsAroundRange( + { from: row.startedAt, to: row.startedAt, inclusiveEnd: true }, + readSameSport, ); - const slotEnd = new Date(slotStart.getTime() + slotMs); - - const clusterRows = await prisma.workout.findMany({ - where: { - userId: user.id, - sportType: row.sportType, - startedAt: { gte: slotStart, lt: slotEnd }, - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - // Part of the canonical session identity consulted by - // `pickCanonicalWorkoutRows` below — without it a HealthKit re-send of - // this very workout stays in the cluster and the deep link can resolve - // to the duplicate instead of the row the list shows. - durationSec: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - }, - }); const userRow = await prisma.user.findUnique({ where: { id: user.id }, @@ -158,17 +197,15 @@ export const GET = apiHandler( const timezone = await resolveUserTimezone(user.id); const dayKey = userDayKey(row.startedAt, timezone); - const canonicalCluster = pickCanonicalWorkoutRows( - clusterRows, + const cluster = canonicalClusterOf( + around, + row.id, userRow?.sourcePriorityJson ?? null, ); - // The canonical pick may carry multiple rows (same winning source); - // pick the closest in time to the requested row so deep-link - // redirects land on the most similar twin. - const canonical = - canonicalCluster.find((c) => c.id === row.id) ?? - canonicalCluster[0] ?? - null; + // The cluster's canonical rows, the requested row first when it is one of + // them and otherwise the nearest in time, so a deep link to a hidden twin + // lands on the most similar row. + const canonical = cluster?.canonical[0] ?? null; const effectiveAvgHeartRate = canonical?.avgHeartRate ?? row.avgHeartRate; const effectiveMaxHeartRate = canonical?.maxHeartRate ?? row.maxHeartRate; @@ -233,43 +270,29 @@ export const GET = apiHandler( // The session before this one in the same sport. "How does this compare // to last time?" is the most likely next question after reading a // workout, and today it costs a trip back to the list plus a scan. - // Scoped to rows that start before this cluster's slot, so a twin of THIS - // session can never be offered as the previous one, and run through the - // same canonical picker so the link lands on the row the list shows. - const previousRows = await prisma.workout.findMany({ - where: { - userId: user.id, - sportType: row.sportType, - startedAt: { lt: slotStart }, - }, - orderBy: [{ startedAt: "desc" }, { id: "desc" }], - // Enough to survive a run of same-session twins and re-sends without - // reading the whole history; the picker collapses them to a handful. - take: 20, - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - durationSec: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - }, - }); - // The picker takes rows in ascending order and does not promise to hand - // them back sorted, so pick the newest survivor explicitly. - const previousWorkoutId = - pickCanonicalWorkoutRows( - [...previousRows].reverse(), - userRow?.sourcePriorityJson ?? null, - ).reduce<{ id: string; startedAt: Date } | null>( - (newest, candidate) => - newest === null || candidate.startedAt > newest.startedAt - ? candidate - : newest, - null, - )?.id ?? null; + // Scoped to rows that start before this session's cluster, so a twin of + // THIS session can never be offered as the previous one, and resolved + // through the same windowed read so the link lands on the row the list + // shows. + const clusterStart = cluster?.members[0]?.startedAt ?? row.startedAt; + const previousWorkoutId = await previousCanonicalWorkoutId( + readSameSport, + async (to, take) => + ( + await prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: { lt: to }, + }, + orderBy: [{ startedAt: "desc" }, { id: "desc" }], + take, + select: { startedAt: true }, + }) + ).map((earlier) => earlier.startedAt), + clusterStart, + userRow?.sourcePriorityJson ?? null, + ); // A route whose track cannot be read (a sealed value that no longer // opens, or an old row stored as a JSON null) is reported as no route at diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index ecdc3b436..877dee0cc 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -128,6 +128,19 @@ function mockInsertReturning( }) => (args.where === undefined && args.select?.id ? rows : [])) as never); } +/** + * Rows the pre-flight `(source, externalId)` probe finds. The probe is the + * read with an `OR` of keys; the windowed read of the stored workouts around + * the batch (a `startedAt` range) finds nothing here. + */ +function mockExistingByKey( + rows: Array<{ source: string; externalId: string }>, +) { + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + where?: { OR?: unknown }; + }) => (args.where?.OR ? rows : [])) as never); +} + function validWorkout( externalId: string, overrides: Record = {}, @@ -345,9 +358,9 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => { }); it("flags entries already in the DB as duplicate without re-inserting", async () => { - vi.mocked(prisma.workout.findMany).mockResolvedValue([ + mockExistingByKey([ { source: "APPLE_HEALTH", externalId: "uuid-existing" }, - ] as never); + ]); vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); const res = await POST( makeRequest({ @@ -825,9 +838,7 @@ describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", () // stays 0 but duplicateCount becomes 1. The hook fires anyway — // the detector is cheap and a duplicate batch can still reveal a // PR if the row was written by another path between dispatches. - vi.mocked(prisma.workout.findMany).mockResolvedValue([ - { source: "APPLE_HEALTH", externalId: "uuid-dup" } as never, - ]); + mockExistingByKey([{ source: "APPLE_HEALTH", externalId: "uuid-dup" }]); vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 }); const res = await POST( makeRequest({ workouts: [validWorkout("uuid-dup")] }), diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 1386b5d61..1dff5e968 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -358,9 +358,11 @@ describe("GET /api/workouts — canonical dedup", () => { where: { userId: "user-1", sportType: "running", + // The range plus one duplicate window on either side, so a twin + // just outside the filter still decides which row is canonical. startedAt: { - gte: new Date("2026-01-01T00:00:00.000Z"), - lte: new Date("2026-01-02T00:00:00.000Z"), + gte: new Date("2025-12-31T23:55:00.000Z"), + lt: new Date("2026-01-02T00:05:00.000Z"), }, }, }), diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 90c88cb54..cc07172c3 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -92,11 +92,11 @@ import { UNSTABLE_EXTERNAL_ID_REASON, type UnstableExternalIdShape, } from "@/lib/validations/external-id"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { batchWorkoutIndicesToKeep } from "@/lib/workouts/batch-dedup"; import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes"; import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; -import { Prisma, type MeasurementSource } from "@/generated/prisma/client"; +import { Prisma } from "@/generated/prisma/client"; // v1.4.25 — push-suppression threshold for workout PRs. A batch // larger than this fires the detection job with `silent: true` so a @@ -508,29 +508,16 @@ async function postBatch(request: NextRequest): Promise { }); } - // Write-time cross-source dedup through the one workout picker the reads - // use, with the same source ladder: one logical workout from two sources - // in one batch (or one HealthKit session re-sent under a new id) lands - // once. Rows the picker hides surface to the client as `duplicate`, so - // the sync cursor advances past them like the externalId case below. - const canonicalPrepared = pickCanonicalWorkoutRows( - prepared.map((p) => ({ - index: p.index, - userId: p.row.userId, - sportType: p.row.sportType, - startedAt: p.row.startedAt as Date, - durationSec: p.row.durationSec, - // Zod's `measurementSourceEnum.optional().default("MANUAL")` - // guarantees `source` is set post-parse, but Prisma's - // `WorkoutCreateManyInput` types it optional. - source: (p.row.source ?? "MANUAL") as MeasurementSource, - avgHeartRate: p.row.avgHeartRate ?? null, - maxHeartRate: p.row.maxHeartRate ?? null, - metadata: p.row.metadata ?? null, - })), + // Write-time cross-source dedup through the one workout clustering the + // reads use (see `batchWorkoutIndicesToKeep`): one logical workout from two + // sources in one batch, or one HealthKit session re-sent under a new id, + // lands once. Rows hidden here surface to the client as `duplicate`, so the + // sync cursor advances past them like the externalId case below. + const survivingIndices = await batchWorkoutIndicesToKeep( + user.id, + prepared, userPriority?.sourcePriorityJson ?? null, ); - const survivingIndices = new Set(canonicalPrepared.map((r) => r.index)); const droppedByWriteDedup: number[] = []; for (const p of prepared) { if (!survivingIndices.has(p.index)) { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 35eddfef6..d75f9f8e5 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -13,6 +13,14 @@ export async function register() { ], ); startEventLoopLagMonitor(); + + // Every locale falls back to the English message catalog, so a process + // without a readable `messages/en.json` cannot serve a page or a job. + // Refuse to start with the file named, rather than fail every request. + const { assertDefaultCatalogReadable } = + await import("@/lib/i18n/server-catalogs"); + assertDefaultCatalogReadable(); + beginBootStep("readiness_summary"); // First-boot readiness summary: a human-readable block on the core secrets diff --git a/src/lib/activity/calendar-read.ts b/src/lib/activity/calendar-read.ts index 470fc0aa9..8fc919d0d 100644 --- a/src/lib/activity/calendar-read.ts +++ b/src/lib/activity/calendar-read.ts @@ -15,7 +15,10 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; @@ -118,26 +121,31 @@ export async function readActivityDays(args: { ), ), includeWorkouts - ? prisma.workout.findMany({ - where: { userId, startedAt: { gte: from, lt: to } }, - select: { - startedAt: true, - durationSec: true, - sportType: true, - source: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - }, - orderBy: { startedAt: "asc" }, - }) - : Promise.resolve([]), + ? readWorkoutsAroundRange({ from, to }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + select: { + id: true, + startedAt: true, + durationSec: true, + sportType: true, + source: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + }), + ) + : Promise.resolve(null), ]); const loadIndex = loads.findIndex((m) => m.size > 0); const load = loadIndex >= 0 ? loads[loadIndex] : null; - const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson); + const workouts = workoutRows + ? canonicalWorkoutsInRange(workoutRows, priorityJson) + : []; const workoutsByDay = new Map< string, { minutes: number; count: number; bySport: Record } diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 536b31ec6..97f0e68be 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -372,9 +372,12 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => // collapse, so a dual-device user's Coach prompt double-counted the // session and inflated `perSport`/`totalInWindow` vs. the app. Pin the fix. it("collapses a same-session cross-source workout pair to one, matching the UI", async () => { - const sharedStart = new Date("2026-06-03T06:30:00Z"); + // Inside the 30-day window: the block keeps only the sessions that + // start in it. + const sharedStart = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); const workouts = [ { + id: "w-apple", sportType: "RUNNING", startedAt: sharedStart, durationSec: 1800, @@ -385,6 +388,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => source: "APPLE_HEALTH", }, { + id: "w-withings", sportType: "RUNNING", // 90 s apart — same 5-minute bucket the picker uses. startedAt: new Date(sharedStart.getTime() + 90_000), diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index a28effb09..bb030f516 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -51,6 +51,7 @@ * is always owner-scoped and safe to read), so the block is `null` only when the * account has no recent readings. */ +import { sameLabUnit } from "@/lib/labs/unit-normalise"; import { UNBOUNDED_REACH, reachFloor, @@ -240,11 +241,26 @@ export async function buildLabsSnapshotBlock( // Newest reading first: the first row seen per biomarker is its MOST RECENT // value, every later row of it feeds its history. Field-by-field `where` — // never a spread. + // + // With an analyte the match runs in SQL, before the row cap: matched in + // memory after it, the newest rows of every other marker used up the cap, + // and the asked-for marker lost its older readings (or vanished) without a + // word. A row's resolved name is its biomarker's when it is linked, its own + // otherwise; the in-memory check below stays the final word. const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, takenAt: floor ? { gte: floor, lte: now } : { lte: now }, + OR: wanted + ? [ + { + biomarkerId: null, + analyte: { contains: wanted, mode: "insensitive" }, + }, + { biomarker: { name: { contains: wanted, mode: "insensitive" } } }, + ] + : undefined, }, orderBy: { takenAt: "desc" }, take: MAX_ROWS, @@ -297,7 +313,12 @@ export async function buildLabsSnapshotBlock( const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; const existing = entries.get(key); if (existing) { - if (wanted && row.value !== null && resolved.unit === existing.unit) { + // One unit written two ways ("mg/dl", "mg/dL") is one series. + if ( + wanted && + row.value !== null && + sameLabUnit(resolved.unit, existing.unit) + ) { existing.earlier.push({ value: row.value, takenAt: row.takenAt, diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts index 9ed3415a4..29c495b94 100644 --- a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts @@ -12,18 +12,23 @@ import type { UnitPreference } from "@/lib/measurements/display-transform"; function build(unitPreference: UnitPreference) { const snapshot: Record = {}; buildWorkoutsBlock({ - workoutRows: [ - { - sportType: "running", - startedAt: new Date("2026-09-20T07:00:00Z"), - durationSec: 1800, - totalEnergyKcal: 300, - totalDistanceM: 5000, - avgHeartRate: 150, - maxHeartRate: 170, - source: "APPLE_HEALTH", - }, - ], + workoutRows: { + range: { from: null, to: null }, + __windowed: true, + rows: [ + { + id: "w1", + sportType: "running", + startedAt: new Date("2026-09-20T07:00:00Z"), + durationSec: 1800, + totalEnergyKcal: 300, + totalDistanceM: 5000, + avgHeartRate: 150, + maxHeartRate: 170, + source: "APPLE_HEALTH", + }, + ], + }, sourcePriorityJson: null, userTz: "UTC", snapshot, diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts index e3d3fd976..634a26833 100644 --- a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts @@ -25,7 +25,10 @@ import { getQuantityTransform, type UnitPreference, } from "@/lib/measurements/display-transform"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + type WindowedWorkouts, +} from "@/lib/workouts/canonical-read"; import type { MeasurementSource } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { tzDayKey, tzWeekday } from "../snapshot-series"; @@ -43,7 +46,8 @@ import type { const WORKOUT_RECENT_CAP = 15; interface WorkoutsBlockContext { - workoutRows: Array<{ + workoutRows: WindowedWorkouts<{ + id: string; sportType: string; startedAt: Date; durationSec: number; @@ -85,7 +89,7 @@ export function buildWorkoutsBlock(ctx: Readonly): void { // `startedAt` ASC (its documented contract) — the builder wants // most-recent-first for the capped `recent` list, so re-establish // DESC order after the pick, same as `GET /api/workouts` does. - const workoutRows = pickCanonicalWorkoutRows( + const workoutRows = canonicalWorkoutsInRange( rawWorkoutRows, sourcePriorityJson ?? null, ).sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime()); diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-read.ts b/src/lib/ai/coach/snapshot-blocks/workouts-read.ts new file mode 100644 index 000000000..5d32f56a9 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/workouts-read.ts @@ -0,0 +1,30 @@ +/** + * The workout rows the Coach's workouts block narrates, read through the + * windowed reader so a session two sources recorded collapses to the row the + * workout list shows, also at the edge of the Coach's window. + */ +import { prisma } from "@/lib/db"; +import { readWorkoutsAroundRange } from "@/lib/workouts/canonical-read"; + +export function readCoachWorkoutRows(userId: string, since: Date) { + return readWorkoutsAroundRange({ from: since, to: null }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + sportType: true, + startedAt: true, + durationSec: true, + totalEnergyKcal: true, + totalDistanceM: true, + avgHeartRate: true, + maxHeartRate: true, + // Needed by the canonical pick in the block builder to collapse a + // dual-source session (e.g. Apple Watch + WHOOP recording the same + // run) to one, matching `GET /api/workouts`. + source: true, + }, + }), + ); +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c9140715b..57ca5fd1a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -79,6 +79,7 @@ import { buildCoarseTimelineTail, type CoarseTimelineTail, } from "./snapshot-series"; +import { readCoachWorkoutRows } from "./snapshot-blocks/workouts-read"; import { buildWorkoutsBlock } from "./snapshot-blocks/workouts-block"; import { buildGlucoseBlock, @@ -567,23 +568,7 @@ async function buildCoachSnapshotImpl( ? readSleepStageRows(userId, sleepRhythmCutoff) : null; const workoutRowsPromise = sources.has("workouts") - ? prisma.workout.findMany({ - where: { userId, startedAt: { gte: additiveCutoff("workouts") } }, - orderBy: { startedAt: "desc" }, - select: { - sportType: true, - startedAt: true, - durationSec: true, - totalEnergyKcal: true, - totalDistanceM: true, - avgHeartRate: true, - maxHeartRate: true, - // v1.30.4 — needed by `pickCanonicalWorkoutRows` in the block - // builder to collapse a dual-source session (e.g. Apple Watch + - // WHOOP recording the same run) to one, matching `GET /api/workouts`. - source: true, - }, - }) + ? readCoachWorkoutRows(userId, additiveCutoff("workouts")) : null; // v1.17.0 — the glucose CLINICAL panel is a fixed 30-day clinical artifact, // identical to the one the insights panel + doctor report render. It must NOT diff --git a/src/lib/cache/closed-block.ts b/src/lib/cache/closed-block.ts index 6a4728e94..fa1408306 100644 --- a/src/lib/cache/closed-block.ts +++ b/src/lib/cache/closed-block.ts @@ -21,3 +21,20 @@ export function closedBlockUntouched(key: string, touchedFrom: Date): boolean { const match = CLOSED_BLOCK_RE.exec(key); return match !== null && Number(match[1]) <= touchedFrom.getTime(); } + +/** + * The earliest `measuredAt` among rows a write touched: the `touchedFrom` a + * writer that knows every row it wrote hands to `invalidateUserMeasurements`. + * `undefined` for no rows, which makes the invalidation drop closed blocks too. + */ +export function earliestMeasuredAt( + rows: Iterable<{ measuredAt: Date }>, +): Date | undefined { + let earliest: Date | undefined; + for (const row of rows) { + if (earliest === undefined || row.measuredAt < earliest) { + earliest = row.measuredAt; + } + } + return earliest; +} diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index cd7318a86..88d644c62 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -92,8 +92,16 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { updatedAt, // One symptom the person put a number on, one they did not. symptomLinks: [ - { severity: 4, symptom: { key: "cramps" } }, - { severity: null, symptom: { key: "fatigue" } }, + { + severity: 4, + symptom: { key: "cramps" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + { + severity: null, + symptom: { key: "fatigue" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, ], }, ]), @@ -171,6 +179,11 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { // Only the rated link is listed. An unrated one is left out rather than // written as a zero, so the file says "never rated" and not "rated none". symptomSeverities: [{ key: "cramps", severity: 4 }], + // When each link was written, every link, disaster recovery only. + symptomLinkCreatedAt: [ + { key: "cramps", createdAt: "2026-04-10T07:00:00.000Z" }, + { key: "fatigue", createdAt: "2026-04-10T07:00:00.000Z" }, + ], }); }); }); diff --git a/src/lib/cycle/backup-schema.ts b/src/lib/cycle/backup-schema.ts index 4dae4e9ca..66dd41e35 100644 --- a/src/lib/cycle/backup-schema.ts +++ b/src/lib/cycle/backup-schema.ts @@ -122,6 +122,9 @@ export const cycleDayLogSchema = z updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), + symptomLinkCreatedAt: z + .array(z.object({ key: z.string().min(1), createdAt: isoDateTime })) + .optional(), }) .passthrough(); /** Cycle-tracking preferences (one row per user). */ diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index c04859d87..6bf55b3d4 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -154,6 +154,12 @@ export interface CycleBackupSection { * never rated it — which is not the same as rating it zero. */ symptomSeverities: Array<{ key: string; severity: number }>; + /** + * When each symptom link was written, by key. Disaster-recovery files + * only, and sparse like `symptomSeverities`; a file without it restores + * the links stamped with the restore. + */ + symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>; }>; /** * The account's OWN symptom definitions. @@ -329,6 +335,14 @@ export async function buildCycleBackupSection( symptomSeverities: d.symptomLinks .filter((l): l is typeof l & { severity: number } => l.severity != null) .map((l) => ({ key: l.symptom.key, severity: l.severity })), + ...(disasterRecovery + ? { + symptomLinkCreatedAt: d.symptomLinks.map((l) => ({ + key: l.symptom.key, + createdAt: l.createdAt.toISOString(), + })), + } + : {}), })), customSymptoms: customSymptoms.map((sym) => ({ ...(disasterRecovery ? { id: sym.id } : {}), @@ -445,6 +459,50 @@ function enumOrNull( : null; } +/** + * The symptom links of one restored day log. + * + * Severities: the day's rated intensities, keyed by symptom. A file written + * before severities were carried has no list at all and every link comes back + * unrated, which is what it was. An entry naming a symptom the day does not + * have is simply never read. Each link's own write time comes back when the + * file carries it (disaster recovery), and is the restore's otherwise. + * + * Every unresolvable key was counted into `skips` by the caller, so dropping + * it here is a reported drop rather than a silent one. The rest of the day is + * unaffected by a symptom chip that will not resolve. + */ +function dayLogSymptomLinks( + d: { + symptomKeys?: string[]; + symptomSeverities?: Array<{ key: string; severity?: number | null }>; + symptomLinkCreatedAt?: Array<{ key: string; createdAt: string }>; + }, + symptomIdByKey: ReadonlyMap, +) { + const severityByKey = new Map(); + for (const s of d.symptomSeverities ?? []) { + const severity = severityOrNull(s.severity); + if (severity !== null) severityByKey.set(s.key, severity); + } + const linkCreatedAt = new Map( + (d.symptomLinkCreatedAt ?? []).map((l) => [l.key, l.createdAt]), + ); + return (d.symptomKeys ?? []).flatMap((k) => { + const id = symptomIdByKey.get(k); + const createdAt = linkCreatedAt.get(k); + return id + ? [ + { + symptomId: id, + severity: severityByKey.get(k) ?? null, + ...(createdAt ? { createdAt: new Date(createdAt) } : {}), + }, + ] + : []; + }); +} + /** * Restore the cycle tables for `ownerId` from a parsed backup payload, * inside an existing transaction. Delete-then-recreate, matching the @@ -617,26 +675,7 @@ export async function restoreCycleData( }; for (const d of payload.cycleDayLogs) { - // The day's rated intensities, keyed by symptom. A file written before - // severities were carried has no list at all and every link comes back - // unrated, which is what it was. An entry naming a symptom the day does not - // have is simply never read. - const severityByKey = new Map(); - for (const s of d.symptomSeverities ?? []) { - const severity = severityOrNull(s.severity); - if (severity !== null) severityByKey.set(s.key, severity); - } - - // Every unresolvable key was counted into `skips` above, so dropping it - // here is a reported drop rather than a silent one. The rest of the day — - // flow, temperature, the encrypted note, every observation the person - // actually wrote — is unaffected by a symptom chip that will not resolve. - const symptomLinks = (d.symptomKeys ?? []).flatMap((k) => { - const id = symptomIdByKey.get(k); - return id - ? [{ symptomId: id, severity: severityByKey.get(k) ?? null }] - : []; - }); + const symptomLinks = dayLogSymptomLinks(d, symptomIdByKey); const cycleId = d.cycleId !== undefined ? d.cycleId === null diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 4d1cd2bf4..35138b89d 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -42,7 +42,11 @@ import { effectiveMoodTz } from "@/lib/mood/date-key"; import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { visitPath } from "@/lib/encounters/visit-links"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + WORKOUT_PICK_SELECT, +} from "@/lib/workouts/canonical-read"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -905,29 +909,24 @@ async function assessmentsPart(frame: DayFrame): Promise { /* ─── workouts, cycle ─────────────────────────────────────────────────────── */ async function workoutsPart(frame: DayFrame): Promise { - const [stored, account] = await Promise.all([ - prisma.workout.findMany({ - where: { - userId: frame.userId, - startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, - }, - select: { - id: true, - source: true, - sportType: true, - startedAt: true, - durationSec: true, - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - }), + const [account, stored] = await Promise.all([ prisma.user.findUnique({ where: { id: frame.userId }, select: { sourcePriorityJson: true }, }), + // The windowed reader orders the rows itself. + readWorkoutsAroundRange( + { from: frame.dayStart, to: frame.dayEnd }, + (bounds) => + prisma.workout.findMany({ + where: { userId: frame.userId, startedAt: bounds }, + select: WORKOUT_PICK_SELECT, + }), + ), ]); // One session synced by two services is one workout on the day, as in the - // workout list it links to. - const rows = pickCanonicalWorkoutRows( + // workout list it links to, a twin across midnight included. + const rows = canonicalWorkoutsInRange( stored, account?.sourcePriorityJson ?? null, ); diff --git a/src/lib/doctor-report/workouts.ts b/src/lib/doctor-report/workouts.ts index dc20b1817..740807c02 100644 --- a/src/lib/doctor-report/workouts.ts +++ b/src/lib/doctor-report/workouts.ts @@ -11,7 +11,10 @@ * session two sources reported counts once. */ import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import type { DoctorReportWorkouts } from "@/lib/doctor-report-types"; /** @@ -29,20 +32,25 @@ export async function loadReportWorkouts(params: { sourcePriorityJson: unknown; }): Promise { const { userId, start, end, days, sourcePriorityJson } = params; - const rows = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: start, lte: end } }, - orderBy: { startedAt: "asc" }, - select: { - sportType: true, - startedAt: true, - durationSec: true, - source: true, - totalDistanceM: true, - totalEnergyKcal: true, - avgHeartRate: true, - }, - }); - const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson); + const rows = await readWorkoutsAroundRange( + { from: start, to: end, inclusiveEnd: true }, + (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + sportType: true, + startedAt: true, + durationSec: true, + source: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + }, + }), + ); + const canonical = canonicalWorkoutsInRange(rows, sourcePriorityJson); return summariseReportWorkouts(canonical, days); } diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 0f5f486d5..d8270f380 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { Buffer } from "node:buffer"; -import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { parseBackupPayload } from "@/lib/validations/backup"; process.env.ENCRYPTION_KEY = @@ -201,6 +201,7 @@ function makePrisma() { enteredUnit: "IU", source: "MANUAL", createdAt: new Date("2026-07-01T08:00:00.000Z"), + updatedAt: new Date("2026-07-01T08:05:00.000Z"), }, ], // v1.40 (#1024) — one ended course with a note, one running. @@ -211,6 +212,7 @@ function makePrisma() { endsOn: new Date("2026-03-07T00:00:00.000Z"), noteEncrypted: encryptToBytes("the March flu"), createdAt: new Date("2026-03-01T08:00:00.000Z"), + updatedAt: new Date("2026-03-01T08:05:00.000Z"), }, { id: "course-june", @@ -218,6 +220,7 @@ function makePrisma() { endsOn: null, noteEncrypted: null, createdAt: new Date("2026-06-10T08:00:00.000Z"), + updatedAt: new Date("2026-06-10T08:05:00.000Z"), }, ], // A two-step ramp, one row carrying an encrypted note and one @@ -374,10 +377,12 @@ function makePrisma() { { rating: 4, moodTag: { key: "sleep_quality", kind: "RATED" }, + createdAt: new Date("2026-07-19T20:00:00.000Z"), }, { rating: null, moodTag: { key: "headache", kind: "BINARY" }, + createdAt: new Date("2026-07-19T20:00:01.000Z"), }, ], }, @@ -393,6 +398,7 @@ function makePrisma() { source: "MANUAL", externalSourceVersion: null, createdAt: new Date("2026-07-19T20:15:00.000Z"), + updatedAt: new Date("2026-07-19T20:16:00.000Z"), }, ]), }, @@ -785,32 +791,47 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { * decision. */ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => { - it.each(["portable-export", "disaster-recovery"] as const)( - "carries every course with its note readable in a %s file", - async (purpose) => { - installSectionMocks(); - const { payload } = await buildFullBackupPayload( - makePrisma() as never, - "user-1", - { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, - ); - const parsed = parseBackupPayload(payload); - expect(parsed.medications[0].courses).toEqual([ - { - startsOn: "2026-03-01", - endsOn: "2026-03-07", - note: "the March flu", - createdAt: "2026-03-01T08:00:00.000Z", - }, - { - startsOn: "2026-06-10", - endsOn: null, - note: null, - createdAt: "2026-06-10T08:00:00.000Z", - }, - ]); - }, - ); + async function courses(purpose: "portable-export" | "disaster-recovery") { + installSectionMocks(); + const { payload } = await buildFullBackupPayload( + makePrisma() as never, + "user-1", + { purpose, exportedAt: new Date("2026-07-20T00:00:00.000Z") }, + ); + return parseBackupPayload(payload).medications[0].courses; + } + + it("carries every course with its note readable in a portable file", async () => { + expect(await courses("portable-export")).toEqual([ + { + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: "the March flu", + createdAt: "2026-03-01T08:00:00.000Z", + }, + { + startsOn: "2026-06-10", + endsOn: null, + note: null, + createdAt: "2026-06-10T08:00:00.000Z", + }, + ]); + }); + + it("carries every course with its note as ciphertext in a disaster-recovery file", async () => { + const [march, june] = (await courses("disaster-recovery"))!; + expect(march).toMatchObject({ + startsOn: "2026-03-01", + endsOn: "2026-03-07", + note: null, + createdAt: "2026-03-01T08:00:00.000Z", + updatedAt: "2026-03-01T08:05:00.000Z", + }); + expect(decryptFromBytes(Buffer.from(march!.noteEncrypted!, "base64"))).toBe( + "the March flu", + ); + expect(june).toMatchObject({ note: null, noteEncrypted: null }); + }); }); describe("buildFullBackupPayload — supplement composition (v1.43)", () => { @@ -836,6 +857,9 @@ describe("buildFullBackupPayload — supplement composition (v1.43)", () => { enteredUnit: "IU", source: "MANUAL", createdAt: "2026-07-01T08:00:00.000Z", + ...(purpose === "disaster-recovery" + ? { updatedAt: "2026-07-01T08:05:00.000Z" } + : {}), }, ]); }, diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index c0d68ee1b..406b60bc1 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -112,7 +112,13 @@ function buildMockPrisma() { feverC: null, noteEncrypted: encryptToBytes("Bad day."), updatedAt: new Date("2026-04-10T00:00:00.000Z"), - symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + symptomLinks: [ + { + severity: 3, + symptom: { key: "headache" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + ], }, ], }, @@ -298,7 +304,13 @@ describe("buildRecordsBackupSection", () => { createdAt, updatedAt, deletedAt, - symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + symptomLinks: [ + { + severity: 3, + symptom: { key: "headache" }, + createdAt: new Date("2026-04-10T07:00:00.000Z"), + }, + ], }, ], }, @@ -384,7 +396,13 @@ describe("buildRecordsBackupSection", () => { createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), deletedAt: deletedAt.toISOString(), - symptoms: [{ key: "headache", severity: 3 }], + symptoms: [ + { + key: "headache", + severity: 3, + createdAt: "2026-04-10T07:00:00.000Z", + }, + ], }, ], }); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index b647ccbf4..45266aa47 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -139,7 +139,12 @@ function sourceClient() { 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" } }], + symptomLinks: [ + { + symptom: { key: "custom:jaw_tension" }, + createdAt: new Date("2026-07-19T08:00:00.000Z"), + }, + ], }, ]), }, diff --git a/src/lib/export/family-history-backup-schema.ts b/src/lib/export/family-history-backup-schema.ts index 2ee8f32ff..eca2a52fd 100644 --- a/src/lib/export/family-history-backup-schema.ts +++ b/src/lib/export/family-history-backup-schema.ts @@ -8,7 +8,7 @@ */ import { z } from "zod/v4"; import { FamilyRelationship } from "@/generated/prisma/enums"; -import { isoDateTime } from "@/lib/export/backup-schema-kit"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; export const familyHistoryBackupSchema = z .object({ @@ -17,7 +17,9 @@ export const familyHistoryBackupSchema = z condition: z.string().min(1), ageAtOnset: z.number().int().nullable().optional(), note: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), + deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); diff --git a/src/lib/export/family-history-backup.ts b/src/lib/export/family-history-backup.ts index d24407eb6..6a597711d 100644 --- a/src/lib/export/family-history-backup.ts +++ b/src/lib/export/family-history-backup.ts @@ -1,26 +1,61 @@ /** * The family history, with both backup ends in one file. */ +import { Buffer } from "node:buffer"; + import type { PrismaClient } from "@/generated/prisma/client"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { toFamilyHistoryEntryDTO, type FamilyHistoryEntryDTO, } from "@/lib/records/dto"; -import type { RestoreContext } from "@/lib/export/restore-context"; +import { + decodeEncryptedBytes, + type RestoreContext, +} from "@/lib/export/restore-context"; // ── Export ─────────────────────────────────────────────────────────────────── -/** The family-history slice of the records section. */ +/** + * A family-history entry as the disaster-recovery file carries it: the note + * as the ciphertext at rest, and the deletion. The portable file carries the + * readable DTO of the live entries only. + */ +export type CanonicalFamilyHistoryEntry = FamilyHistoryEntryDTO & { + notesEncrypted?: string | null; + deletedAt?: string | null; +}; + +/** + * The family-history slice of the records section. A disaster-recovery file + * carries deleted entries too, as every other record with a tombstone does: + * a restore from it has to give back the account as it was, deletions + * included, and the file used to drop them. + */ export async function buildFamilyHistoryBackupSlice( prisma: Pick, userId: string, -): Promise<{ familyHistory: FamilyHistoryEntryDTO[] }> { + disasterRecovery: boolean, +): Promise<{ familyHistory: CanonicalFamilyHistoryEntry[] }> { const familyRows = await prisma.familyHistoryEntry.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { createdAt: "desc" }, }); - const familyHistory = familyRows.map(toFamilyHistoryEntryDTO); + const familyHistory: CanonicalFamilyHistoryEntry[] = disasterRecovery + ? familyRows.map((row) => ({ + id: row.id, + relationship: row.relationship, + condition: row.condition, + ageAtOnset: row.ageAtOnset, + note: null, + notesEncrypted: row.notesEncrypted + ? Buffer.from(row.notesEncrypted).toString("base64") + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + })) + : familyRows.map(toFamilyHistoryEntryDTO); return { familyHistory }; } @@ -44,8 +79,19 @@ export async function restoreFamilyHistory(ctx: RestoreContext): Promise { relationship: familyEntry.relationship as never, condition: familyEntry.condition, ageAtOnset: familyEntry.ageAtOnset ?? null, + // The ciphertext verbatim when the file carries it, the readable note + // sealed here otherwise. notesEncrypted: - familyEntry.note == null ? null : encryptToBytes(familyEntry.note), + familyEntry.notesEncrypted !== undefined + ? familyEntry.notesEncrypted === null + ? null + : decodeEncryptedBytes(familyEntry.notesEncrypted) + : familyEntry.note == null + ? null + : encryptToBytes(familyEntry.note), + deletedAt: familyEntry.deletedAt + ? new Date(familyEntry.deletedAt) + : null, ...(familyEntry.createdAt ? { createdAt: new Date(familyEntry.createdAt) } : {}), diff --git a/src/lib/export/illness-backup-schema.ts b/src/lib/export/illness-backup-schema.ts index 09f6de025..2022d5ced 100644 --- a/src/lib/export/illness-backup-schema.ts +++ b/src/lib/export/illness-backup-schema.ts @@ -18,6 +18,9 @@ export const illnessSymptomBackupSchema = z .object({ key: z.string().min(1), severity: z.number().int().nullable().optional(), + // When the link was written. Disaster-recovery files only; without it the + // link is stamped with the restore, as before. + createdAt: isoDateTime.optional(), }) .passthrough(); export const illnessDayLogBackupSchema = z diff --git a/src/lib/export/illness-backup.ts b/src/lib/export/illness-backup.ts index 64486bb95..19eb4423c 100644 --- a/src/lib/export/illness-backup.ts +++ b/src/lib/export/illness-backup.ts @@ -94,6 +94,7 @@ export async function buildIllnessBackupSlice( symptoms: dayLog.symptomLinks.map((link) => ({ key: link.symptom.key, severity: link.severity, + createdAt: link.createdAt.toISOString(), })), })), })) @@ -168,7 +169,14 @@ async function restoreEpisodes(ctx: RestoreContext): Promise { if (episode.parentConditionId) { await tx.illnessEpisode.update({ where: { id: episode.id, userId: ownerId }, - data: { parentConditionId: episode.parentConditionId }, + // The pointer is a second write to the row; the row's own last write + // stays the one the file carries. + data: { + parentConditionId: episode.parentConditionId, + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, }); } } @@ -212,7 +220,15 @@ async function restoreDayLogs(ctx: RestoreContext): Promise { const symptomLinks = dayLog.symptoms.flatMap((symptom) => { const symptomId = symptomByKey.get(symptom.key); return symptomId - ? [{ symptomId, severity: symptom.severity ?? null }] + ? [ + { + symptomId, + severity: symptom.severity ?? null, + ...(symptom.createdAt + ? { createdAt: new Date(symptom.createdAt) } + : {}), + }, + ] : []; }); await tx.illnessDayLog.create({ diff --git a/src/lib/export/labs-backup-schema.ts b/src/lib/export/labs-backup-schema.ts index b28577007..df15b82e6 100644 --- a/src/lib/export/labs-backup-schema.ts +++ b/src/lib/export/labs-backup-schema.ts @@ -81,6 +81,9 @@ export const biomarkerBackupSchema = z // The catalogue analyte the marker resolves to. analyteKey: z.string().nullable().optional(), context: z.string().nullable().optional(), + // The context's ciphertext, disaster-recovery files only. A file without + // it carries the readable context, which the restore seals. + contextEncrypted: base64BytesSchema.nullable().optional(), // The other spellings the marker answers to. Absent on // files written before them. aliases: z diff --git a/src/lib/export/labs-backup.ts b/src/lib/export/labs-backup.ts index f2e5291d5..2205385d9 100644 --- a/src/lib/export/labs-backup.ts +++ b/src/lib/export/labs-backup.ts @@ -103,6 +103,11 @@ export interface BiomarkerBackupEntry { panel: string | null; hidden: boolean; context: string | null; + /** + * The context's ciphertext as base64, disaster-recovery files only, where + * `context` is null. A file without it carries the readable context. + */ + contextEncrypted?: string | null; /** The catalogue key the marker's name and unit resolve to. */ analyteKey?: string | null; /** @@ -259,11 +264,22 @@ export async function buildLabsBackupSlice( upperBound: b.upperBound, panel: b.panel, hidden: b.hidden, - context: readForExport( - b.contextEncrypted, - "biomarker context", - decryptContextFromBytes, - ), + // Ciphertext verbatim in a disaster-recovery file, like every other + // sealed column; the readable context in a portable one. + ...(disasterRecovery + ? { + context: null, + contextEncrypted: b.contextEncrypted + ? Buffer.from(b.contextEncrypted).toString("base64") + : null, + } + : { + context: readForExport( + b.contextEncrypted, + "biomarker context", + decryptContextFromBytes, + ), + }), analyteKey: b.analyteKey, aliases: aliasesByMarker.get(b.id) ?? [], })); @@ -347,9 +363,13 @@ export async function restoreLabMarkers(ctx: RestoreContext): Promise { panel: biomarker.panel ?? null, hidden: biomarker.hidden ?? false, contextEncrypted: - biomarker.context == null - ? null - : encryptContextToBytes(biomarker.context), + biomarker.contextEncrypted !== undefined + ? biomarker.contextEncrypted === null + ? null + : decodeEncryptedBytes(biomarker.contextEncrypted) + : biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), analyteKey: biomarker.analyteKey ?? null, ...(biomarker.createdAt ? { createdAt: new Date(biomarker.createdAt) } diff --git a/src/lib/export/medications-backup-schema.ts b/src/lib/export/medications-backup-schema.ts index eade10b55..4b43396fd 100644 --- a/src/lib/export/medications-backup-schema.ts +++ b/src/lib/export/medications-backup-schema.ts @@ -109,6 +109,9 @@ export const medicationIngredientSchema = z enteredUnit: z.string().max(8).nullable().optional(), source: z.enum(["MANUAL", "LABEL_AI"]).default("MANUAL"), createdAt: isoDateTime.optional(), + // The row's last write. Disaster-recovery files only; a file without it + // restores the row stamped with the restore, as before. + updatedAt: isoDateTime.optional(), }) .passthrough(); /** @@ -121,7 +124,11 @@ export const medicationCourseSchema = z startsOn: z.iso.date(), endsOn: z.iso.date().nullable(), note: z.string().max(280).nullable().optional(), + // The note's ciphertext, disaster-recovery files only. A file without it + // (any written before) carries the readable note, which the restore seals. + noteEncrypted: base64BytesSchema.nullable().optional(), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); /** @@ -248,12 +255,21 @@ export const medicationEfficacyTargetSchema = z export const customMedicationCategorySchema = z .object({ key: z.string().regex(/^custom:[0-9a-f-]{36}$/), - label: z.string().trim().min(1).max(40), + // The readable label: a portable file, and every disaster-recovery file + // written before the label travelled as ciphertext. + label: z.string().trim().min(1).max(40).nullable().optional(), + // The label's ciphertext, as a disaster-recovery file carries it now. + labelEncrypted: base64BytesSchema.optional(), sortOrder: z.number().int().min(0).default(0), isActive: z.boolean().default(true), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) - .passthrough(); + .passthrough() + .refine((c) => c.labelEncrypted !== undefined || (c.label ?? "") !== "", { + message: "a custom category needs its label or its ciphertext", + path: ["label"], + }); export const medicationSchema = z .object({ id: z.string().min(1).optional(), @@ -293,6 +309,9 @@ export const medicationSchema = z // whole file. Since v1.40 it may be `custom:`, the key of an entry // in `customMedicationCategories`. category: z.string().max(64).optional(), + // When the category was last set. Disaster-recovery files only; without + // it the restored assignment is stamped with the restore, as before. + categoryUpdatedAt: isoDateTime.nullable().optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), diff --git a/src/lib/export/medications-backup.ts b/src/lib/export/medications-backup.ts index 2ffbfd80c..990d579e5 100644 --- a/src/lib/export/medications-backup.ts +++ b/src/lib/export/medications-backup.ts @@ -54,6 +54,9 @@ const MEDICATION_BACKUP_INCLUDE = { { id: "asc" }, ] as const, }, + // When the category was last set. Read for the disaster-recovery file, + // which gives the assignment back with its own timestamp. + categoryAssignment: { select: { updatedAt: true } }, // What the drug was supposed to move. The biomarker is read by NAME // rather than by id, exactly as the lab results beside it are: a // portable restore mints a fresh biomarker id, and `(userId, name)` is @@ -101,6 +104,8 @@ function serialiseMedicationRow( externalSource: m.externalSource, externalId: m.externalId, updatedAt: m.updatedAt.toISOString(), + categoryUpdatedAt: + m.categoryAssignment?.updatedAt.toISOString() ?? null, } : // No id: a portable restore mints a fresh one. The relation links // still name this drug, so the file says which row they mean. @@ -206,7 +211,9 @@ function serialiseMedicationHistory( // counting toward the day's micronutrients. Canonical amount plus the // amount as entered, so the form shows what was typed. ingredients: m.ingredients.map((i) => ({ - ...(disasterRecovery ? { id: i.id } : {}), + ...(disasterRecovery + ? { id: i.id, updatedAt: i.updatedAt.toISOString() } + : {}), nutrient: i.nutrient, amountPerUnit: i.amountPerUnit, unit: i.unit, @@ -215,10 +222,21 @@ function serialiseMedicationHistory( source: i.source, createdAt: i.createdAt.toISOString(), })), + // The note follows the contract of every other sealed column: the + // disaster-recovery file carries the ciphertext verbatim (and the row's + // own last write), a portable file the decrypted text. courses: m.courses.map((c) => ({ startsOn: dayKeyOfDate(c.startsOn), endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, - note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, + ...(disasterRecovery + ? { + note: null, + noteEncrypted: c.noteEncrypted + ? Buffer.from(c.noteEncrypted).toString("base64") + : null, + updatedAt: c.updatedAt.toISOString(), + } + : { note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null }), createdAt: c.createdAt.toISOString(), })), // Titration: when the dose moved, to what, and why. The drug comes @@ -473,11 +491,15 @@ export async function buildMedicationsBackupPart( prisma, ); // The account's own categories, restored before the medications that name - // them by key. Labels travel decrypted so a file restores on an instance - // with a different key; every one is carried, hidden ones included, - // because a medication can still be filed under it. + // them by key. Every one is carried, hidden ones included, because a + // medication can still be filed under it. A portable file carries the + // label decrypted, so it restores on an instance with another key; a + // disaster-recovery file carries the ciphertext verbatim, like every other + // sealed column, and the row's own last write. const customMedicationCategories = - await readMedicationCategoryLabelsForBackup(userId, prisma); + await readMedicationCategoryLabelsForBackup(userId, prisma, { + disasterRecovery, + }); // Where each archived era sits in its OWN drug's ordered list. Built once // here rather than per row, and scoped per medication because the // supersede pointer never crosses a drug — every route that writes it diff --git a/src/lib/export/medications-restore.ts b/src/lib/export/medications-restore.ts index 90c7d5cb2..d71a2505f 100644 --- a/src/lib/export/medications-restore.ts +++ b/src/lib/export/medications-restore.ts @@ -77,10 +77,18 @@ async function restoreCustomCategories( data: { userId: ownerId, key, - labelEncrypted: encryptCategoryLabel(c.label), + // The ciphertext verbatim when the file carries it (a + // disaster-recovery file), the readable label sealed here otherwise + // (a portable file, or a recovery file written before the label + // travelled sealed). + labelEncrypted: + c.labelEncrypted !== undefined + ? decodeEncryptedBytes(c.labelEncrypted) + : encryptCategoryLabel(c.label ?? ""), sortOrder: c.sortOrder, isActive: c.isActive, ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), }, }); } @@ -254,6 +262,7 @@ function medicationChildrenData(m: BackupMedication, ownerId: string) { enteredUnit: i.enteredUnit ?? null, source: i.source, ...(i.createdAt ? { createdAt: new Date(i.createdAt) } : {}), + ...(i.updatedAt ? { updatedAt: new Date(i.updatedAt) } : {}), })), }, // `resumedAt` passes through as null when the era is open. It is not @@ -341,14 +350,20 @@ async function restoreCourses( startsOn: Date; endsOn: Date | null; note: string | null; + noteEncrypted?: string | null; createdAt?: Date; + updatedAt?: Date; }> = m.courses !== undefined ? m.courses.map((c) => ({ startsOn: dateOfDayKey(c.startsOn), endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null, note: c.note ?? null, + ...(c.noteEncrypted !== undefined + ? { noteEncrypted: c.noteEncrypted } + : {}), ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), })) : (() => { const derived = courseFromWindow( @@ -365,8 +380,18 @@ async function restoreCourses( userId: ownerId, startsOn: c.startsOn, endsOn: c.endsOn, - noteEncrypted: c.note ? encryptToBytes(c.note) : null, + // Ciphertext verbatim from a disaster-recovery file; the readable note + // of a portable or older file sealed here. + noteEncrypted: + c.noteEncrypted !== undefined + ? c.noteEncrypted === null + ? null + : decodeEncryptedBytes(c.noteEncrypted) + : c.note + ? encryptToBytes(c.note) + : null, ...(c.createdAt ? { createdAt: c.createdAt } : {}), + ...(c.updatedAt ? { updatedAt: c.updatedAt } : {}), })), }); const window = projectCourseWindow(courseRows); @@ -589,6 +614,12 @@ export async function restoreMedications(ctx: RestoreContext): Promise { categoryKeyRemap.get(m.category) ?? m.category, tx, ); + if (m.categoryUpdatedAt) { + await tx.medicationCategoryAssignment.update({ + where: { medicationId: created.id }, + data: { updatedAt: new Date(m.categoryUpdatedAt) }, + }); + } } await restoreCourses(tx, ownerId, m, created, restoreTz); await restoreScheduleRevisions( @@ -598,6 +629,14 @@ export async function restoreMedications(ctx: RestoreContext): Promise { medicationIndex, unresolvedRevisionLinks, ); + // The writes above (the course window) moved the row's `updatedAt` to + // now; a file that carries the row's own last write puts it back. + if (m.updatedAt) { + await tx.medication.update({ + where: { id: created.id }, + data: { updatedAt: new Date(m.updatedAt) }, + }); + } for (const target of m.efficacyTargets) { links.pendingEfficacyTargets.push({ medicationId: created.id, target }); } diff --git a/src/lib/export/mood-backup-schema.ts b/src/lib/export/mood-backup-schema.ts index 4f571f411..a61ea130c 100644 --- a/src/lib/export/mood-backup-schema.ts +++ b/src/lib/export/mood-backup-schema.ts @@ -81,6 +81,12 @@ export const moodEntrySchema = z // RATED-only meaning, so every file written before this one parses // unchanged and restores identically: absent reads as none. structuredTags: z.array(z.string().min(1)).default([]), + // When each link (rated or not) was written, by tag key. Disaster-recovery + // files only; a file without it restores the links stamped with the + // restore, as before. + linkCreatedAt: z + .array(z.object({ key: z.string().min(1), createdAt: isoDateTime })) + .optional(), // The day context, when the entry had one. Optional and nullable at the // top: a file written before this existed carries no `context` key at all // and must parse and restore exactly as it did. Every field inside is @@ -116,6 +122,9 @@ export const moodEntrySchema = z // plain text, a disaster-recovery file the ciphertext. note: z.string().nullable().optional(), notesEncrypted: base64BytesSchema.nullable().optional(), + // The context row's own stamps, disaster-recovery files only. + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough() .nullable() diff --git a/src/lib/export/mood-backup.ts b/src/lib/export/mood-backup.ts index 439a3f4d9..69f4cc380 100644 --- a/src/lib/export/mood-backup.ts +++ b/src/lib/export/mood-backup.ts @@ -60,6 +60,7 @@ const MOOD_ENTRY_BACKUP_SELECT = { */ const MOOD_ENTRY_TAG_LINK_SELECT = { rating: true, + createdAt: true, moodTag: { select: { key: true, kind: true } }, } as const satisfies Prisma.MoodEntryTagLinkSelect; @@ -69,9 +70,8 @@ const MOOD_ENTRY_TAG_LINK_SELECT = { * `moodEntryId` and `userId` are absent for the reasons the exclusion list in * `mood-backup-completeness.test.ts` states: the context rides inside its * entry and is re-bound to whatever id that entry got, and the owner is - * re-derived from the restoring account. The two row stamps are absent for the - * third reason there — they describe when the sub-row was written, not when - * the day happened, and the entry already carries the moment that matters. + * re-derived from the restoring account. The two row stamps travel in the + * disaster-recovery file only, which gives the account back as it was. * * Named rather than inlined so the guard can compare it field-by-field against * the model, exactly as it does for the two selects above. This is the whole @@ -97,6 +97,8 @@ const MOOD_CONTEXT_BACKUP_SELECT = { eventValence: true, eventAt: true, notesEncrypted: true, + createdAt: true, + updatedAt: true, } as const satisfies Prisma.MoodContextSelect; type BackupMoodContext = Prisma.MoodContextGetPayload<{ @@ -149,6 +151,8 @@ function serialiseMoodContext( notesEncrypted: moodContext.notesEncrypted ? Buffer.from(moodContext.notesEncrypted).toString("base64") : null, + createdAt: moodContext.createdAt.toISOString(), + updatedAt: moodContext.updatedAt.toISOString(), } : { note: readNote(moodContext.notesEncrypted, null) }), workStatus: moodContext.workStatus, @@ -220,6 +224,11 @@ function serialiseMoodEntry( syncVersion: moodEntry.syncVersion, createdAt: moodEntry.createdAt.toISOString(), updatedAt: moodEntry.updatedAt.toISOString(), + // When each link was written, by tag key, for both link arms. + linkCreatedAt: moodEntry.tagLinks.map((tagLink) => ({ + key: tagLink.moodTag.key, + createdAt: tagLink.createdAt.toISOString(), + })), } : { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }), id: moodEntry.id, diff --git a/src/lib/export/mood-restore.ts b/src/lib/export/mood-restore.ts index 938195124..26596eb76 100644 --- a/src/lib/export/mood-restore.ts +++ b/src/lib/export/mood-restore.ts @@ -256,6 +256,9 @@ async function restoreMoodContext( context.notesEncrypted == null ? encryptNote(context.note ?? null) : decodeEncryptedBytes(context.notesEncrypted), + // The row's own stamps when the file carries them (disaster recovery). + ...(context.createdAt ? { createdAt: new Date(context.createdAt) } : {}), + ...(context.updatedAt ? { updatedAt: new Date(context.updatedAt) } : {}), }, }); } @@ -320,7 +323,16 @@ async function restoreMoodEntry( ? [{ moodEntryId: restored.id, moodTagId, rating: null }] : []; }); - const links = [...factorLinks, ...tagLinks]; + // Each link's own write time, when the file carries it. + const linkCreatedAt = new Map( + (entry.linkCreatedAt ?? []).map((link) => [link.key, link.createdAt]), + ); + const keyOfTag = new Map(); + for (const [key, id] of [...factorByKey, ...tagByKey]) keyOfTag.set(id, key); + const links = [...factorLinks, ...tagLinks].map((link) => { + const createdAt = linkCreatedAt.get(keyOfTag.get(link.moodTagId) ?? ""); + return createdAt ? { ...link, createdAt: new Date(createdAt) } : link; + }); if (links.length > 0) { await tx.moodEntryTagLink.createMany({ data: links }); } diff --git a/src/lib/export/nutrients-backup-schema.ts b/src/lib/export/nutrients-backup-schema.ts index edd766e67..b7b701e93 100644 --- a/src/lib/export/nutrients-backup-schema.ts +++ b/src/lib/export/nutrients-backup-schema.ts @@ -29,5 +29,8 @@ export const nutrientDaySchema = z // Absent from files written before v1.39.7. externalSourceVersion: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + // Disaster-recovery files only; without it the day is stamped with the + // restore, as before. + updatedAt: isoDateTime.optional(), }) .passthrough(); diff --git a/src/lib/export/nutrients-backup.ts b/src/lib/export/nutrients-backup.ts index 09693fc4d..ecfef114a 100644 --- a/src/lib/export/nutrients-backup.ts +++ b/src/lib/export/nutrients-backup.ts @@ -33,6 +33,7 @@ export async function buildNutrientsBackupPart( source: true, externalSourceVersion: true, createdAt: true, + updatedAt: true, }, orderBy: [{ day: "desc" }, { nutrient: "asc" }], }); @@ -46,6 +47,11 @@ export async function buildNutrientsBackupPart( source: n.source, externalSourceVersion: n.externalSourceVersion, createdAt: n.createdAt.toISOString(), + // The day's last write, for the disaster-recovery file, which gives + // the account back as it was. + ...(ctx.disasterRecovery + ? { updatedAt: n.updatedAt.toISOString() } + : {}), })), }, counts: { nutrientDays: nutrientDays.length }, @@ -79,6 +85,7 @@ export async function restoreNutrientDays(ctx: RestoreContext): Promise { source: n.source, externalSourceVersion: n.externalSourceVersion ?? null, ...(n.createdAt ? { createdAt: new Date(n.createdAt) } : {}), + ...(n.updatedAt ? { updatedAt: new Date(n.updatedAt) } : {}), })), }); } diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 756b89f37..485aa97fe 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -122,7 +122,7 @@ export async function buildRecordsBackupSection( buildLabsBackupSlice(prisma, userId, disasterRecovery), buildIllnessBackupSlice(prisma, userId, disasterRecovery), buildAllergiesBackupSlice(prisma, userId, disasterRecovery), - buildFamilyHistoryBackupSlice(prisma, userId), + buildFamilyHistoryBackupSlice(prisma, userId, disasterRecovery), buildWorkoutsBackupSlice(prisma, userId, disasterRecovery), buildDocumentsBackupSlice( prisma, diff --git a/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts b/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts new file mode 100644 index 000000000..cd709fccf --- /dev/null +++ b/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts @@ -0,0 +1,91 @@ +/** + * A message catalog that cannot be read costs its own locale, not every + * request. + * + * The catalogs are read from `messages/` at run time. A missing or corrupt + * file used to throw from `serverCatalog`, and because the matchers iterate + * every locale (`localisedValues`), one bad non-English file made every + * request that touched them a 500. Now that locale is served the English + * catalog and the failure is logged once; the English catalog itself, which + * everything falls back to, stops the process at boot with the file named. + */ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() })); + +import { emitSignal } from "@/lib/logging/signal"; + +import { localisedValues } from "../shared-resolve"; +import { + assertDefaultCatalogReadable, + serverCatalog, +} from "../server-catalogs"; + +const CACHE = Symbol.for("healthlog.i18n.serverCatalogs"); +const EN = { common: { save: "Save" } }; + +function clearCatalogCache() { + (globalThis as { [CACHE]?: Map })[CACHE]?.clear(); +} + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "hl-catalogs-")); + mkdirSync(join(root, "messages")); + writeFileSync(join(root, "messages", "en.json"), JSON.stringify(EN)); + writeFileSync(join(root, "messages", "de.json"), '{"common": {"save": '); + vi.spyOn(process, "cwd").mockReturnValue(root); + vi.mocked(emitSignal).mockClear(); + clearCatalogCache(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + clearCatalogCache(); + rmSync(root, { recursive: true, force: true }); +}); + +describe("a catalog that cannot be read", () => { + it("serves a corrupt locale the English catalog and logs it once", () => { + expect(serverCatalog("de")).toEqual(EN); + expect(serverCatalog("de")).toEqual(EN); + expect(emitSignal).toHaveBeenCalledTimes(1); + expect(emitSignal).toHaveBeenCalledWith( + expect.objectContaining({ + action: "i18n.catalog.fallback", + level: "warn", + meta: { locale: "de", reason: "corrupt", fallback: "en" }, + }), + ); + }); + + it("serves a missing locale the English catalog", () => { + expect(serverCatalog("fr")).toEqual(EN); + expect(emitSignal).toHaveBeenCalledWith( + expect.objectContaining({ + meta: { locale: "fr", reason: "missing", fallback: "en" }, + }), + ); + }); + + it("keeps a matcher that reads every locale answering", () => { + expect(localisedValues("common.save")).toEqual(["Save"]); + }); + + it("refuses to boot without the English catalog, naming the file", () => { + rmSync(join(root, "messages", "en.json")); + expect(() => assertDefaultCatalogReadable()).toThrow( + /en message catalog at .*messages[/\\]en\.json is missing/, + ); + }); + + it("refuses to boot on an English catalog that does not parse", () => { + writeFileSync(join(root, "messages", "en.json"), "[1, 2"); + expect(() => assertDefaultCatalogReadable()).toThrow(/is corrupt/); + }); +}); diff --git a/src/lib/i18n/server-catalogs.ts b/src/lib/i18n/server-catalogs.ts index cd9f1b555..138cd9f29 100644 --- a/src/lib/i18n/server-catalogs.ts +++ b/src/lib/i18n/server-catalogs.ts @@ -18,12 +18,23 @@ * milliseconds. * * The directory is `messages/` under the working directory: the checkout in - * development and tests, `/app` in the image, which copies it there. + * development and tests, `/app` in the image, which copies it there (and the + * image build checks every locale's file is there and parses). + * + * A catalog that cannot be read is not every request's problem. A missing or + * corrupt non-English catalog is logged once per process and that locale is + * served the English one; before, the read threw, and since the matchers + * iterate every locale, one bad file turned every request into a 500. The + * English catalog is the fallback itself, so a process without it cannot serve + * at all: the boot check (`assertDefaultCatalogReadable`) refuses to start one + * with a message that names the file. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { locales, type Locale } from "./config"; +import { emitSignal } from "@/lib/logging/signal"; + +import { defaultLocale, locales, type Locale } from "./config"; export type ServerCatalog = Record; @@ -51,11 +62,87 @@ export function serverCatalog(locale: Locale): ServerCatalog { if (!(locales as readonly string[]).includes(locale)) { throw new Error(`unknown locale: ${String(locale)}`); } - const parsed = JSON.parse( - readFileSync(join(catalogDirectory(), `${locale}.json`), "utf8"), - ) as ServerCatalog; - loaded.set(locale, parsed); - return parsed; + const read = readCatalog(locale); + if (read.ok) { + loaded.set(locale, read.catalog); + return read.catalog; + } + if (locale === defaultLocale) { + throw new Error(defaultCatalogMessage(read)); + } + // Logged once: the fallback is cached under the locale, so the next read + // of it is a cache hit. + emitSignal({ + action: "i18n.catalog.fallback", + level: "warn", + meta: { locale, reason: read.reason, fallback: defaultLocale }, + error: read.error, + }); + const fallback = serverCatalog(defaultLocale); + loaded.set(locale, fallback); + return fallback; +} + +type CatalogRead = + | { ok: true; catalog: ServerCatalog } + | { + ok: false; + path: string; + reason: "missing" | "unreadable" | "corrupt"; + error: unknown; + }; + +function readCatalog(locale: Locale): CatalogRead { + const path = join(catalogDirectory(), `${locale}.json`); + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + const missing = (error as NodeJS.ErrnoException)?.code === "ENOENT"; + return { + ok: false, + path, + reason: missing ? "missing" : "unreadable", + error, + }; + } + try { + const parsed: unknown = JSON.parse(text); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + return { + ok: false, + path, + reason: "corrupt", + error: new Error("the catalog is not a JSON object"), + }; + } + return { ok: true, catalog: parsed as ServerCatalog }; + } catch (error) { + return { ok: false, path, reason: "corrupt", error }; + } +} + +function defaultCatalogMessage(read: Extract) { + const detail = + read.error instanceof Error ? read.error.message : String(read.error); + return ( + `The ${defaultLocale} message catalog at ${read.path} is ${read.reason} ` + + `(${detail}). Every other locale falls back to it, so the server cannot ` + + `serve without it; the image must ship messages/*.json next to server.js.` + ); +} + +/** + * Boot check: read the English catalog now and throw a message naming the + * file when it is missing or does not parse, so a broken image stops at boot + * instead of failing every request. + */ +export function assertDefaultCatalogReadable(): void { + serverCatalog(defaultLocale); } /** Locales whose catalog this process has read so far (for tests and logs). */ diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 03ea6f010..84c061b7a 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -9,6 +9,7 @@ * - the per-day idempotency key + insufficient-data gate. */ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { canonicalWorkoutTrimps } from "@/lib/workouts/canonical-read"; /** * The post-mutation tail reads the real `@/lib/db` client, not the fake this @@ -32,7 +33,6 @@ import { percentile, resolvePersonalReference, persistStrainScore, - canonicalWorkoutTrimps, canonicalDayActiveEnergy, STRAIN_ACTIVE_ENERGY_REFERENCE, STRAIN_SCORE_EXTERNAL_ID_PREFIX, diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index cce0f970f..25f971c18 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -76,7 +76,11 @@ import { userDayKey, } from "@/lib/tz/format"; import { MIN_PAIRED_N } from "@/lib/insights/correlations"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + WORKOUT_PICK_SELECT, +} from "@/lib/workouts/canonical-read"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { @@ -1256,19 +1260,7 @@ export async function fetchTrainingSeries( tz: string, since: Date, ): Promise { - const [workouts, account, trimp] = await Promise.all([ - prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - take: WORKOUT_READ_CAP, - select: { - id: true, - source: true, - sportType: true, - startedAt: true, - durationSec: true, - }, - }), + const [account, trimp, workouts] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -1280,8 +1272,16 @@ export async function fetchTrainingSeries( take: 1000, select: { day: true, dayTrimp: true }, }), + readWorkoutsAroundRange({ from: since, to: null }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: WORKOUT_READ_CAP, + select: WORKOUT_PICK_SELECT, + }), + ), ]); - const sessions = pickCanonicalWorkoutRows( + const sessions = canonicalWorkoutsInRange( workouts, account?.sourcePriorityJson ?? null, ); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 5e3916870..acd01bf57 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -28,7 +28,10 @@ import { readRollupBuckets, ROLLUP_FOLD_WINDOW_MS, } from "@/lib/rollups/measurement-rollups"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import type { AggregatedFeatures, @@ -536,29 +539,32 @@ export async function readWorkoutsBlock( now: number, ): Promise { const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); - const [stored, account] = await Promise.all([ - prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: [{ startedAt: "desc" }, { id: "asc" }], - take: 2000, - select: { - source: true, - sportType: true, - startedAt: true, - durationSec: true, - totalDistanceM: true, - }, - }), + const [account, stored] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, }), + readWorkoutsAroundRange({ from: since, to: null }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + take: 2000, + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + totalDistanceM: true, + }, + }), + ), ]); // One session synced by two services (or re-sent by HealthKit) is one // workout: the same collapse the workout list applies, so the count and the // minutes here match what the person sees there. The picker returns its rows // oldest first; the block reads the newest at index 0. - const rows = pickCanonicalWorkoutRows( + const rows = canonicalWorkoutsInRange( stored, account?.sourcePriorityJson ?? null, ).reverse(); diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 863776d17..526a2527d 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -73,11 +73,11 @@ import { } from "@/lib/insights/score-row"; import { shiftDateKey } from "@/lib/tz/format"; import { - pickCanonicalWorkoutRows, - workoutDuplicateClusterKeys, - workoutSourceLadder, - type WorkoutPickerRow, -} from "@/lib/measurements/pick-canonical-workout-rows"; + canonicalWorkoutTrimps, + readWorkoutsAroundRange, + rowsInRange, + startsInRange, +} from "@/lib/workouts/canonical-read"; import { pickCanonicalSourceRows, type SourcePickerRow, @@ -432,24 +432,28 @@ export async function computeStrainScore( // The day's workouts + their HR series, with the session identity the // cross-source picker needs: one run recorded by the watch and synced again // through a second service is one training impulse, not two. - const [workouts, account] = await Promise.all([ - prisma.workout.findMany({ - where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - select: { - id: true, - source: true, - sportType: true, - startedAt: true, - durationSec: true, - samples: { select: { samples: true } }, - }, - take: 100, - }), + const [account, workouts] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, }), + // Read through the windowed reader: a session whose twin lies across + // midnight is counted once, on the day the list shows it. + readWorkoutsAroundRange({ from: dayStart, to: dayEnd }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + samples: { select: { samples: true } }, + }, + take: 100, + }), + ), ]); // The watch and the phone relay (or a ring) both write the day's active @@ -465,7 +469,7 @@ export async function computeStrainScore( let dayTrimp = 0; let workoutsWithSeries = 0; if (canTrimp) { - const trimpOf = (w: (typeof workouts)[number]): number => { + const trimpOf = (w: (typeof workouts.rows)[number]): number => { const raw = w.samples?.samples; if (!Array.isArray(raw)) return 0; return banisterTrimp( @@ -476,9 +480,10 @@ export async function computeStrainScore( ); }; const sessions = canonicalWorkoutTrimps( - workouts, + workouts.rows, account?.sourcePriorityJson ?? null, trimpOf, + (at) => startsInRange(workouts.range, at), ); for (const trimp of sessions) { dayTrimp += trimp; @@ -535,7 +540,7 @@ export async function computeStrainScore( return { score: null, reason: - workouts.length > 0 && !canTrimp + rowsInRange(workouts).length > 0 && !canTrimp ? "insufficient_profile" : "insufficient_inputs", dayTrimp: 0, @@ -572,81 +577,6 @@ export function canonicalDayActiveEnergy< return best; } -/** - * One training impulse per workout SESSION, not per stored row. - * - * The same session can be stored more than once: the watch writes it to Apple - * Health and a second service (Strava, WHOOP, a ring) syncs it again, or - * HealthKit re-sends it under a new identifier. The workout list collapses - * those through `pickCanonicalWorkoutRows`; summing every row here counted the - * session twice in the day's TRIMP while the list showed it once. - * - * The picker chooses the row by source order, which says nothing about heart - * rate: the winning row can be one without a series while its dropped twin - * carries one. So the impulse of a slot is the sum over its picked rows, and - * only when that is zero the largest impulse among the slot's dropped rows. - * - * The picker keeps every row of a slot none of whose sources is on the - * ladder, so the picked rows of one slot can come from several sources. Only - * one source's rows count: the best-ranked on the ladder, a source off the - * ladder ranking below every listed one, and among sources off the ladder - * the one with the largest impulse. Rows of that one source still add up - * (two distinct sessions of the same sport in one duplicate cluster). - * Returns the positive impulses, one per session. Pure; exported for tests. - */ -export function canonicalWorkoutTrimps< - T extends WorkoutPickerRow & { id: string }, ->( - rows: readonly T[], - priorityJson: unknown, - trimpOf: (row: T) => number, -): number[] { - // By id: the picker may hand back a copy of a row (it fills a missing heart - // rate from a WHOOP twin). - const picked = new Set( - pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), - ); - const ladder = workoutSourceLadder(priorityJson); - const rankOf = (source: string) => { - const index = ladder.indexOf(source as (typeof ladder)[number]); - return index === -1 ? ladder.length : index; - }; - const slots = new Map< - string, - { picked: Map; dropped: number } - >(); - const keys = workoutDuplicateClusterKeys(rows); - for (const [index, row] of rows.entries()) { - const slot = slots.get(keys[index]!) ?? { picked: new Map(), dropped: 0 }; - const trimp = trimpOf(row); - if (trimp > 0) { - if (picked.has(row.id)) { - slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp); - } else { - slot.dropped = Math.max(slot.dropped, trimp); - } - } - slots.set(keys[index]!, slot); - } - const out: number[] = []; - for (const slot of slots.values()) { - let best: { rank: number; trimp: number } | null = null; - for (const [source, trimp] of slot.picked) { - const rank = rankOf(source); - if ( - best === null || - rank < best.rank || - (rank === best.rank && trimp > best.trimp) - ) { - best = { rank, trimp }; - } - } - const trimp = best !== null ? best.trimp : slot.dropped; - if (trimp > 0) out.push(trimp); - } - return out; -} - /** * Build the Strain profile (age + sex) and resting HR from the user row + * the latest resting-HR reading. `prisma` is the (worker) client. diff --git a/src/lib/jobs/__tests__/workout-insight-generate.test.ts b/src/lib/jobs/__tests__/workout-insight-generate.test.ts index f1443b204..95d54083f 100644 --- a/src/lib/jobs/__tests__/workout-insight-generate.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-generate.test.ts @@ -50,8 +50,23 @@ vi.mock("@/lib/insights/status-provider", () => ({ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ encryptToBytes: vi.fn(() => new Uint8Array([1, 2, 3])), })); -vi.mock("@/lib/measurements/pick-canonical-workout-rows", () => ({ - pickCanonicalWorkoutRows: vi.fn((rows) => rows), +vi.mock("@/lib/workouts/canonical-read", () => ({ + readWorkoutsAroundRange: vi.fn( + async ( + range: { from: Date | null; to: Date | null }, + read: (bounds: { gte?: Date; lt?: Date }) => Promise, + ) => ({ + rows: await read({ + gte: range.from ?? undefined, + lt: range.to ?? undefined, + }), + range, + __windowed: true, + }), + ), + canonicalWorkoutsInRange: vi.fn((windowed: { rows: unknown[] }) => [ + ...windowed.rows, + ]), })); import { @@ -636,8 +651,8 @@ describe("Activity Insight — locale", () => { */ describe("Activity Insight — own-history comparison", () => { it("routes the history rows through the canonical picker", async () => { - const { pickCanonicalWorkoutRows } = - await import("@/lib/measurements/pick-canonical-workout-rows"); + const { canonicalWorkoutsInRange } = + await import("@/lib/workouts/canonical-read"); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, locale: "en", @@ -650,20 +665,27 @@ describe("Activity Insight — own-history comparison", () => { await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW); - expect(pickCanonicalWorkoutRows).toHaveBeenCalledTimes(1); + expect(canonicalWorkoutsInRange).toHaveBeenCalledTimes(1); // The user's own source ladder decides the winner, not a default. - expect(vi.mocked(pickCanonicalWorkoutRows).mock.calls[0][1]).toEqual({ + expect(vi.mocked(canonicalWorkoutsInRange).mock.calls[0][1]).toEqual({ ladder: ["APPLE_HEALTH"], }); }); - it("excludes the workout being described from its own baseline", async () => { + it("excludes the workout being described, twins included, from its own baseline", async () => { + const { canonicalWorkoutsInRange } = + await import("@/lib/workouts/canonical-read"); await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW); const where = vi.mocked(prisma.workout.findMany).mock.calls[0][0] - ?.where as { userId: string; id: { not: string } }; + ?.where as { userId: string; id?: unknown }; expect(where.userId).toBe(USER); - expect(where.id).toEqual({ not: WORKOUT }); + // The row stays in the read so its whole duplicate cluster can be left + // out after the pick; dropping it in SQL left a twin behind. + expect(where.id).toBeUndefined(); + expect(vi.mocked(canonicalWorkoutsInRange).mock.calls[0][2]).toEqual({ + excludeClusterOf: WORKOUT, + }); }); }); diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 1023cc0f8..f2cbfe0d9 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -26,7 +26,7 @@ */ import { unlinkSync } from "node:fs"; import { getHeapStatistics } from "node:v8"; -import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import type { PrismaClient } from "@/generated/prisma/client"; import { prisma, toJson } from "@/lib/db"; import type { Job } from "pg-boss"; @@ -40,18 +40,13 @@ import { type WorkoutRouteReference, } from "@/lib/apple-health/route-import"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; -import { trackBackgroundTask } from "@/lib/logging/background-tasks"; +import { afterBulkImport } from "@/lib/jobs/import-tail"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { streamParseExportXml, type ImportJobProgress, type ImportJobResult, } from "@/lib/measurements/import-apple-health-export"; -import { - recomputeUserRollups, - ROLLUP_FOLD_WINDOW_MS, -} from "@/lib/rollups/measurement-rollups"; import { withBackgroundEvent } from "@/lib/logging/background"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { @@ -589,70 +584,13 @@ export async function handleAppleHealthImport( }, }); - // v1.5.0 — fold the persistent rollup table for the imported - // user. The per-row write hooks are intentionally skipped on the - // streaming-ingest path (a 100k-row import would otherwise pay - // 100k DAY-recompute round-trips); we run the rollup once at - // the end, scoped to the user's full measurement span, so - // post-import reads of the analytics + comprehensive surfaces - // hit the warm rollup table on first paint. - // - // The miss rides out as a fact rather than as a failed job: the - // import is committed and its mirror row already reads `done`, so - // failing here would report a completed import as failed and - // contradict the row the status poll surfaces to the operator. - let rollupFailed = false; - try { - // Folded over what this import wrote, not over the account's whole - // history, and never further back than the fold window every other - // path keeps to: buckets older than `ROLLUP_FOLD_WINDOW_MS` are by - // design never written, so reads there fall back to live SQL. The old - // span ran from the account's first measurement, whatever the import - // carried, and folded any history older than the window as well. - const span = result.measuredSpan; - if (span) { - const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); - const spanFrom = new Date(span.from); - const from = spanFrom > windowStart ? spanFrom : windowStart; - // `to` is exclusive in the aggregator, so one millisecond past the - // newest row. - const to = new Date(new Date(span.to).getTime() + 1); - if (to > from) { - await recomputeUserRollups(userId, { from, to }); - } - } - } catch (rollupErr) { - // Rollup failure is non-fatal — the next read falls through to - // live aggregation. Log but don't poison the import. - rollupFailed = true; - console.warn( - `[apple-health-import] Rollup recompute failed for user ${userId}`, - rollupErr, - ); - } - - // The imported types' cached status assessments describe data that just - // changed; re-warm them, the second leg every other measurement write - // runs (`afterMeasurementMutation`). The rollups above are refolded over - // the import's span rather than per day, so the leg is called directly. - const importedTypes = Object.entries(result.perType) - .filter(([, stat]) => stat.inserted + stat.updated > 0) - .map(([type]) => type as MeasurementType); - if (importedTypes.length > 0) { - // Registered so the test suite can await the detached handle. - trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, importedTypes, { - // An export file the person chose to import, narrated like any - // other import rather than held to the sync gap. - source: "manual", - }).catch((err) => { - console.warn( - `[apple-health-import] status-insight invalidate failed for user ${userId}`, - err, - ); - }), - ); - } + // Rollups, measurement caches and status assessments over what the + // import wrote (`afterBulkImport`). + const { rollupFailed } = await afterBulkImport( + userId, + result, + "apple-health-import", + ); // Best-effort cleanup. A failed unlink is not fatal — `/tmp` is // periodically swept on the host. diff --git a/src/lib/jobs/health-connect-import-worker.ts b/src/lib/jobs/health-connect-import-worker.ts index b4928e022..08850c72a 100644 --- a/src/lib/jobs/health-connect-import-worker.ts +++ b/src/lib/jobs/health-connect-import-worker.ts @@ -19,7 +19,6 @@ import type { Job } from "pg-boss"; import { caughtAs, logCaught } from "@/lib/logging/signal"; -import type { MeasurementType } from "@/generated/prisma/client"; import { prisma, toJson } from "@/lib/db"; import { extractHealthConnectDb } from "@/lib/import/unzip-export-xml"; import { @@ -28,15 +27,10 @@ import { } from "@/lib/import/health-connect/import"; import { HealthConnectImportError } from "@/lib/import/health-connect/reader"; import { connectedDirectIntegrations } from "@/lib/import/health-connect/skip-packages"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; -import { trackBackgroundTask } from "@/lib/logging/background-tasks"; +import { afterBulkImport } from "@/lib/jobs/import-tail"; import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; -import { - recomputeUserRollups, - ROLLUP_FOLD_WINDOW_MS, -} from "@/lib/rollups/measurement-rollups"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; export const HEALTH_CONNECT_IMPORT_QUEUE = "health-connect-import"; @@ -203,43 +197,13 @@ export async function runHealthConnectImport( }, }); - // The per-row tails are skipped on a bulk import; refold the rollups over - // what this import wrote, inside the window every other path keeps to, - // and re-warm the cached assessments of the types it touched. - let rollupFailed = false; - const span = result.measuredSpan; - if (span) { - try { - const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); - const spanFrom = new Date(span.from); - const from = spanFrom > windowStart ? spanFrom : windowStart; - const to = new Date(new Date(span.to).getTime() + 1); - if (to > from) await recomputeUserRollups(userId, { from, to }); - } catch (err) { - rollupFailed = true; - console.warn( - `[health-connect-import] Rollup recompute failed for user ${userId}`, - err, - ); - } - } - const touched = Object.entries(result.perType) - .filter(([, stat]) => stat.inserted + stat.updated > 0) - .map(([type]) => type as MeasurementType); - if (touched.length > 0) { - trackBackgroundTask( - invalidateStatusInsightsForTypes(userId, touched, { - // An export file the person chose to import, like the Apple Health - // import: narrated like a hand entry, not held to the sync gap. - source: "manual", - }).catch((err) => { - console.warn( - `[health-connect-import] status-insight invalidate failed for user ${userId}`, - err, - ); - }), - ); - } + // Rollups, measurement caches and status assessments over what the + // import wrote, as the Apple Health import does (`afterBulkImport`). + const { rollupFailed } = await afterBulkImport( + userId, + result, + "health-connect-import", + ); annotate({ action: { name: "import.health-connect.done" }, diff --git a/src/lib/jobs/import-tail.ts b/src/lib/jobs/import-tail.ts new file mode 100644 index 000000000..a63dbf71d --- /dev/null +++ b/src/lib/jobs/import-tail.ts @@ -0,0 +1,82 @@ +/** + * The tail of a bulk export import (Apple Health, Health Connect), run once + * the import is committed. The per-row write hooks are skipped while the + * export streams in (a 100k-row import would otherwise pay 100k DAY + * recomputes), so the three legs every other measurement write runs through + * `afterMeasurementMutation` run here once, over what the import wrote: + * + * 1. the rollup table, refolded over the span of the rows written and never + * further back than the fold window every other path keeps to (buckets + * older than `ROLLUP_FOLD_WINDOW_MS` are by design never written, so + * reads there fall back to live SQL); + * 2. the measurement read caches, marked stale back to the oldest sample + * written. An export carries years of history, and a closed block that a + * later sync leaves in place would otherwise keep serving the pre-import + * all-time values. An import that wrote no measurement (only workouts or + * ECGs) drops them all; + * 3. the cached status assessments of the types written, narrated like a + * hand entry: an export is a file the person chose to import. + * + * A rollup miss rides out as a fact rather than a failed job: the import is + * committed and its row already reads `done`, so failing here would report a + * completed import as failed. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalidation"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; +import { + recomputeUserRollups, + ROLLUP_FOLD_WINDOW_MS, +} from "@/lib/rollups/measurement-rollups"; + +export async function afterBulkImport( + userId: string, + result: { + measuredSpan: { from: string; to: string } | null; + perType: Record; + }, + label: string, +): Promise<{ rollupFailed: boolean }> { + const span = result.measuredSpan; + let rollupFailed = false; + if (span) { + try { + const windowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS); + const spanFrom = new Date(span.from); + const from = spanFrom > windowStart ? spanFrom : windowStart; + // `to` is exclusive in the aggregator: one millisecond past the newest. + const to = new Date(new Date(span.to).getTime() + 1); + if (to > from) await recomputeUserRollups(userId, { from, to }); + } catch (err) { + rollupFailed = true; + console.warn( + `[${label}] Rollup recompute failed for user ${userId}`, + err, + ); + } + } + + invalidateUserMeasurements( + userId, + span ? { touchedFrom: new Date(span.from) } : undefined, + ); + + const types = Object.entries(result.perType) + .filter(([, stat]) => stat.inserted + stat.updated > 0) + .map(([type]) => type as MeasurementType); + if (types.length > 0) { + // Registered so the test suite can await the detached handle. + trackBackgroundTask( + invalidateStatusInsightsForTypes(userId, types, { + source: "manual", + }).catch((err) => { + console.warn( + `[${label}] status-insight invalidate failed for user ${userId}`, + err, + ); + }), + ); + } + return { rollupFailed }; +} diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index 699a68aa7..2f17773bb 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -44,7 +44,10 @@ import { WORKOUT_INSIGHT_PROMPT_VERSION, } from "@/lib/ai/prompts/workout-insight"; import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import type { Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { finalizeStatusSummary } from "@/lib/insights/status-shared"; @@ -357,37 +360,37 @@ export async function runWorkoutInsightGenerate( whoopZoneDurations: parseWhoopZoneDurations(row.metadata), }); - // The current workout is excluded in SQL before canonicalization. The - // canonical picker then collapses cross-source twins before any median or - // prompt is built. - const historyRows = await prisma.workout.findMany({ - where: { - userId, - sportType: row.sportType, - id: { not: row.id }, - startedAt: { - gte: new Date( - row.startedAt.getTime() - OWN_HISTORY_LOOKBACK_DAYS * MS_PER_DAY, - ), - lt: row.startedAt, - }, + // The canonical sessions of the sport before this one, without this + // session's duplicate cluster: a twin a second source recorded a minute + // earlier is not the previous session. + const historyRows = await readWorkoutsAroundRange( + { + from: new Date( + row.startedAt.getTime() - OWN_HISTORY_LOOKBACK_DAYS * MS_PER_DAY, + ), + to: row.startedAt, }, - orderBy: { startedAt: "desc" }, - take: OWN_HISTORY_MAX_ROWS, - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - durationSec: true, - avgHeartRate: true, - totalDistanceM: true, - totalEnergyKcal: true, - }, - }); - const history = pickCanonicalWorkoutRows( + (bounds) => + prisma.workout.findMany({ + where: { userId, sportType: row.sportType, startedAt: bounds }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + take: OWN_HISTORY_MAX_ROWS, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + durationSec: true, + avgHeartRate: true, + totalDistanceM: true, + totalEnergyKcal: true, + }, + }), + ); + const history = canonicalWorkoutsInRange( historyRows, profile?.sourcePriorityJson ?? null, + { excludeClusterOf: row.id }, ); const evidence = buildWorkoutInsightEvidence({ diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts index 009643764..6379ca114 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { canonicalWorkoutTrimps } from "@/lib/insights/strain-score"; +import { canonicalWorkoutTrimps } from "@/lib/workouts/canonical-read"; import { pickCanonicalWorkoutRows, diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index de26a85ef..cd46fbccb 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -133,8 +133,20 @@ function deduplicateWinningRows(rows: T[]): T[] { * within the window straddle a slot boundary, the clusters are exactly the * old slots: every pair inside one slot is less than five minutes apart, and * a cluster cannot reach past its slot without such a pair. + * + * Because a cluster is anchored on its first start, which cluster a workout + * lands in depends on the workouts before it. A reader that decides which row + * is canonical therefore reads through `readWorkoutsAroundRange` in + * `src/lib/workouts/canonical-read.ts`, which widens the read until the + * clusters of the requested range no longer depend on where the read began. */ -const DUPLICATE_WINDOW_MS = 5 * 60 * 1000; +export const WORKOUT_DUPLICATE_WINDOW_MS = 5 * 60 * 1000; +const DUPLICATE_WINDOW_MS = WORKOUT_DUPLICATE_WINDOW_MS; + +/** Rows of different groups never share a duplicate cluster. */ +export function workoutClusterGroup(row: WorkoutPickerRow): string { + return `${row.userId ?? ""}:${row.sportType}`; +} /** * The duplicate cluster of every row, aligned with `rows`. Rows of different @@ -151,7 +163,7 @@ export function workoutDuplicateClusterKeys( ): string[] { const groups = new Map(); rows.forEach((row, index) => { - const group = `${row.userId ?? ""}:${row.sportType}`; + const group = workoutClusterGroup(row); const list = groups.get(group); if (list) list.push(index); else groups.set(group, [index]); diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index f6240db5a..00ef279ee 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { randomUUID } from "node:crypto"; import type { Prisma } from "@/generated/prisma/client"; @@ -254,13 +255,16 @@ export async function listMedicationCategoryLabels( export async function readMedicationCategoryLabelsForBackup( userId: string, client: CategoryClient = prisma, + options: { disasterRecovery?: boolean } = {}, ): Promise< Array<{ key: string; - label: string; + label: string | null; + labelEncrypted?: string; sortOrder: number; isActive: boolean; createdAt: string; + updatedAt?: string; }> > { const rows = await client.medicationCategoryLabel.findMany({ @@ -272,14 +276,23 @@ export async function readMedicationCategoryLabelsForBackup( sortOrder: true, isActive: true, createdAt: true, + updatedAt: true, }, }); return rows.map((r) => ({ key: r.key, - label: decryptCategoryLabel(r.labelEncrypted), + ...(options.disasterRecovery + ? { + label: null, + labelEncrypted: Buffer.from(r.labelEncrypted).toString("base64"), + } + : { label: decryptCategoryLabel(r.labelEncrypted) }), sortOrder: r.sortOrder, isActive: r.isActive, createdAt: r.createdAt.toISOString(), + ...(options.disasterRecovery + ? { updatedAt: r.updatedAt.toISOString() } + : {}), })); } diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 818f6080c..1fec9ab70 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -336,7 +336,7 @@ export const workoutPaths: NonNullable = { tags: ["Measurements"], summary: "List workouts (v1.4.32)", description: - "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", + "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", requestParams: { query: z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), diff --git a/src/lib/oura/sync-heart-rate.ts b/src/lib/oura/sync-heart-rate.ts index 17597a23e..48f6d66b3 100644 --- a/src/lib/oura/sync-heart-rate.ts +++ b/src/lib/oura/sync-heart-rate.ts @@ -19,12 +19,8 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { getEvent } from "@/lib/logging/context"; +import { collapseToTypeDayKeys } from "@/lib/rollups/measurement-rollups"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { forEachHeartRatePage, mapHeartRateSample, @@ -120,23 +116,13 @@ export async function refoldOuraTouchedDays( touched: TouchedDays, ): Promise { if (touched.size === 0) return; - const keys = touched.list(); - try { - await recomputeBucketsForKeys(userId, keys); - invalidateStatusInsightsForTypes( - userId, - [...new Set(keys.map((k) => k.type))], - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `oura: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `oura: heart-rate rollup recompute failed for ${userId}: ${err}`, - ); - } + // The shared tail: the rollups, the measurement read caches back to the + // first touched day (a backfill window reaches years back), and the cached + // assessments of the types. + await afterMeasurementMutation(userId, touched.list(), { + source: "sync", + label: "oura heart rate", + }); } /** diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index c205e05e5..fe4f89984 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -45,17 +45,12 @@ import { toFailureKind, type FailureKind, } from "@/lib/integrations/status"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; import { emitInsertedMeasurementArrivals, type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { @@ -895,23 +890,13 @@ export async function upsertOuraMeasurements( () => {}, ); } - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `oura: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `oura: rollup recompute failed for ${userId}: ${err}`, - ); - } + // The shared tail: the rollups, the measurement read caches back to the + // oldest reading written (a history backfill writes through here and has + // no other invalidation), and the cached assessments of the types. + await afterMeasurementMutation(userId, touched, { + source: "sync", + label: "oura", + }); return imported; } diff --git a/src/lib/rollups/after-measurement-mutation.ts b/src/lib/rollups/after-measurement-mutation.ts index aa501d1d6..c96bbecdf 100644 --- a/src/lib/rollups/after-measurement-mutation.ts +++ b/src/lib/rollups/after-measurement-mutation.ts @@ -12,6 +12,16 @@ * inherently: a day left empty drops its rollup row. * 2. the cached per-metric status assessments — re-warmed via * `invalidateStatusInsightsForTypes` (fire-and-forget). + * 3. the measurement read caches (analytics, targets, derived, workouts), + * marked stale back to the earliest touched reading once the rollups + * are refolded. Without it a writer that relied on the tail alone (the + * CSV and JSON imports, the score engines, the WHOOP webhook, the Withings + * activity, sleep and ECG legs, the Oura history backfill) left + * every cached read, the closed history blocks above all, on the + * pre-write values until their TTL. A caller that invalidated already + * (an interactive write evicts) loses nothing: marking stale after an + * eviction only catches an entry rebuilt from the old rollups between + * the two. * * Both legs are best-effort: a populator hiccup never fails the user's * mutation. Callers collect the affected `(type, measuredAt)` pairs @@ -27,6 +37,8 @@ import { type StatusIngestSource, } from "@/lib/insights/status-invalidation"; import { trackBackgroundTask } from "@/lib/logging/background-tasks"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { earliestMeasuredAt } from "@/lib/cache/closed-block"; import type { MeasurementType } from "@/generated/prisma/client"; export async function afterMeasurementMutation( @@ -54,6 +66,7 @@ export async function afterMeasurementMutation( } catch (err) { console.warn(`[${label}] rollup recompute failed`, err); } + invalidateUserMeasurements(userId, { touchedFrom: earliestMeasuredAt(rows) }); // Status-insight re-warm is fire-and-forget — it must run even when // the rollup leg above failed, so the two legs never gate each other. diff --git a/src/lib/sleep/sweep-stale-segments.ts b/src/lib/sleep/sweep-stale-segments.ts index e1503d692..b5607c1d4 100644 --- a/src/lib/sleep/sweep-stale-segments.ts +++ b/src/lib/sleep/sweep-stale-segments.ts @@ -30,6 +30,7 @@ import type { MeasurementSource } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { getEvent } from "@/lib/logging/context"; /** One just-fetched sleep session: its externalId prefix + the fresh ids. */ @@ -79,5 +80,9 @@ export async function sweepStaleSleepSegments( ); } } + // A tombstoned segment changes the nights the cached reads hold. The sweep + // does not know how far back the swept rows reach, so every cached block + // goes; it removes rows only when a session was re-scored, which is rare. + if (removed > 0) invalidateUserMeasurements(userId); return removed; } diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 9fa6732c5..b5199cb0a 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -44,11 +44,7 @@ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { getUnitForType } from "@/lib/validations/measurement"; import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { hasActivityScope, @@ -578,26 +574,12 @@ export async function syncUserActivity( // v1.4.39.1 — refresh the persistent rollup table for every distinct // (type, day) the sync touched. Collapsed so a multi-day Withings // catch-up costs at most ~N (type, day) recomputes. - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - - // v1.8.0 — drop the per-metric assessment caches the synced types - // dirty (steps feed the general overview). Fire-and-forget. - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `withings activity: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `withings activity: rollup recompute failed for ${userId}: ${err}`, - ); - } + // The shared tail: the rollups, the measurement read caches back to the + // oldest touched reading, and the cached assessments of the types. + await afterMeasurementMutation(userId, touched, { + source: "sync", + label: "withings activity", + }); // Only a clean write stamps success. A swallowed batch-write left the // ledger honest via the transient failure recorded above. diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 865532dc5..477a8de07 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -54,11 +54,7 @@ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { persistEcgRecording } from "@/lib/ecg/persist-recording"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { WithingsApiError, @@ -517,23 +513,12 @@ export async function syncUserEcg( // Refresh the persistent rollup tier for every distinct (type, day) touched, // then drop the per-metric assessment caches the new events dirty. - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `withings ecg: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `withings ecg: rollup recompute failed for ${userId}: ${err}`, - ); - } + // The shared tail: the rollups, the measurement read caches back to the + // oldest touched reading, and the cached assessments of the types. + await afterMeasurementMutation(userId, touched, { + source: "sync", + label: "withings ecg", + }); if (sourceWriteError) { await recordWithingsSyncFailure( diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 25b7abaca..c8b99eaf6 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -43,11 +43,7 @@ import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { getUnitForType } from "@/lib/validations/measurement"; -import { - collapseToTypeDayKeys, - recomputeBucketsForKeys, -} from "@/lib/rollups/measurement-rollups"; -import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { @@ -693,27 +689,12 @@ export async function syncUserSleep( // v1.4.39.1 — refresh the persistent rollup table for every distinct // (type, day) the sync touched. Sleep segments collapse heavily — // ~10 stage rows per night land in one DAY recompute. - try { - const keys = collapseToTypeDayKeys(touched); - await recomputeBucketsForKeys(userId, keys); - - // v1.8.0 — drop the per-metric assessment caches the synced types - // dirty (sleep + resting-HR feed the pulse / general cards). - // Fire-and-forget. - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - { source: "sync" }, - ).catch((err) => { - getEvent()?.addWarning( - `withings sleep: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); - } catch (err) { - getEvent()?.addWarning( - `withings sleep: rollup recompute failed for ${userId}: ${err}`, - ); - } + // The shared tail: the rollups, the measurement read caches back to the + // oldest touched reading, and the cached assessments of the types. + await afterMeasurementMutation(userId, touched, { + source: "sync", + label: "withings sleep", + }); // If a stage segment for last night just landed, kick the debounced // morning refresh so the digest/score finalise with the current sleep. The diff --git a/src/lib/workouts/__tests__/canonical-read.property.test.ts b/src/lib/workouts/__tests__/canonical-read.property.test.ts new file mode 100644 index 000000000..09f38c438 --- /dev/null +++ b/src/lib/workouts/__tests__/canonical-read.property.test.ts @@ -0,0 +1,240 @@ +/** + * The windowed read gives every surface the canonical workouts a read of the + * whole history would give, whatever range it asked for. + * + * The store is an in-memory array behind the same bounded-read callback the + * Prisma readers pass, so the property covers the reader's reach-back loop and + * the range filter, not a copy of them. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { assertAsyncProperty } from "../../../../tests/property/fast-check"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; + +import { + canonicalClusterOf, + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + startsInRange, + type WorkoutStartBounds, + type WorkoutStartRange, +} from "../canonical-read"; + +type Source = "APPLE_HEALTH" | "WITHINGS" | "WHOOP" | "MANUAL"; + +interface Row { + id: string; + startedAt: Date; + durationSec: number; + sportType: string; + source: Source; + avgHeartRate: number | null; + maxHeartRate: number | null; + metadata: null; +} + +const MIN = 60_000; +const BASE = Date.UTC(2026, 9, 1, 9, 0, 0); + +function memoryStore(rows: readonly Row[]) { + let reads = 0; + const read = async (bounds: WorkoutStartBounds) => { + reads += 1; + return rows + .filter( + (row) => + (!bounds.gte || row.startedAt >= bounds.gte) && + (!bounds.lt || row.startedAt < bounds.lt), + ) + .sort( + (a, b) => + a.startedAt.getTime() - b.startedAt.getTime() || + a.id.localeCompare(b.id), + ); + }; + return { read, reads: () => reads }; +} + +function sortedAll(rows: readonly Row[]): Row[] { + return [...rows].sort( + (a, b) => + a.startedAt.getTime() - b.startedAt.getTime() || a.id.localeCompare(b.id), + ); +} + +/** What a read of the whole history says is canonical inside the range. */ +function oracle(rows: readonly Row[], range: WorkoutStartRange): string[] { + return pickCanonicalWorkoutRows(sortedAll(rows), null) + .filter((row) => startsInRange(range, row.startedAt)) + .map((row) => row.id) + .sort(); +} + +/** + * Workouts packed tightly enough that chains of starts under five minutes + * apart are common: the case where the cluster of a row depends on rows far + * before it. + */ +const rowsArb = fc + .array( + fc.record({ + gapMin: fc.integer({ min: 0, max: 9 }), + gapSec: fc.integer({ min: 0, max: 59 }), + sportType: fc.constantFrom("running", "running", "cycling"), + source: fc.constantFrom( + "APPLE_HEALTH", + "WITHINGS", + "WHOOP", + "MANUAL", + ), + durationMin: fc.integer({ min: 10, max: 60 }), + hr: fc.option(fc.integer({ min: 90, max: 170 }), { nil: null }), + }), + { minLength: 1, maxLength: 40 }, + ) + .map((specs) => { + let at = BASE; + return specs.map((spec, index): Row => { + at += spec.gapMin * MIN + spec.gapSec * 1000; + return { + id: `w${String(index).padStart(2, "0")}`, + startedAt: new Date(at), + durationSec: spec.durationMin * 60, + sportType: spec.sportType, + source: spec.source, + avgHeartRate: spec.hr, + maxHeartRate: null, + metadata: null, + }; + }); + }); + +const rangeArb = fc.record({ + fromOffsetMin: fc.option(fc.integer({ min: -10, max: 400 }), { nil: null }), + lengthMin: fc.option(fc.integer({ min: 0, max: 200 }), { nil: null }), + inclusiveEnd: fc.boolean(), +}); + +function toRange(spec: { + fromOffsetMin: number | null; + lengthMin: number | null; + inclusiveEnd: boolean; +}): WorkoutStartRange { + const from = + spec.fromOffsetMin === null + ? null + : new Date(BASE + spec.fromOffsetMin * MIN); + const to = + spec.lengthMin === null + ? null + : new Date((from?.getTime() ?? BASE) + spec.lengthMin * MIN); + return { from, to, inclusiveEnd: spec.inclusiveEnd }; +} + +describe("readWorkoutsAroundRange", () => { + it("resolves the range's canonical workouts as a whole-history read does", async () => { + await assertAsyncProperty( + fc.asyncProperty(rowsArb, rangeArb, async (rows, rangeSpec) => { + const range = toRange(rangeSpec); + const store = memoryStore(rows); + const windowed = await readWorkoutsAroundRange(range, store.read); + const got = canonicalWorkoutsInRange(windowed, null) + .map((row) => row.id) + .sort(); + expect(got).toEqual(oracle(rows, range)); + }), + 300, + ); + }); + + it("gives one workout the same cluster whichever range found it", async () => { + await assertAsyncProperty( + fc.asyncProperty(rowsArb, fc.nat(), async (rows, pick) => { + const target = rows[pick % rows.length]!; + const store = memoryStore(rows); + const point = await readWorkoutsAroundRange( + { from: target.startedAt, to: target.startedAt, inclusiveEnd: true }, + store.read, + ); + const whole = await readWorkoutsAroundRange( + { from: null, to: null }, + store.read, + ); + const fromPoint = canonicalClusterOf(point, target.id, null); + const fromWhole = canonicalClusterOf(whole, target.id, null); + expect(fromPoint?.members.map((row) => row.id)).toEqual( + fromWhole?.members.map((row) => row.id), + ); + expect(fromPoint?.canonical[0]?.id).toBe(fromWhole?.canonical[0]?.id); + }), + 300, + ); + }); + + it("depends on the history before the range, which the range alone does not show", async () => { + // Three runs four minutes apart. Read from 09:55 the 09:58 run anchors a + // cluster with 10:02, and 10:06 stands alone; read from 10:00 the 10:02 + // run anchors one with 10:06 and hides it behind the Apple row. The + // windowed read reaches back to 09:58 and draws the clusters a + // whole-history read draws. + const at = (h: number, m: number) => new Date(Date.UTC(2026, 9, 1, h, m)); + const rows: Row[] = [ + ["a", at(9, 58), "WITHINGS"], + ["b", at(10, 2), "APPLE_HEALTH"], + ["c", at(10, 6), "WITHINGS"], + ].map(([id, startedAt, source]) => ({ + id: id as string, + startedAt: startedAt as Date, + durationSec: 1800, + sportType: "running", + source: source as Source, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + })); + const range = { from: at(10, 0), to: at(11, 0) }; + + // The range read alone drops the 10:06 run as a twin of 10:02. + const naive = pickCanonicalWorkoutRows( + rows.filter((row) => startsInRange(range, row.startedAt)), + null, + ).map((row) => row.id); + expect(naive).toEqual(["b"]); + + const windowed = await readWorkoutsAroundRange( + range, + memoryStore(rows).read, + ); + expect( + canonicalWorkoutsInRange(windowed, null).map((row) => row.id), + ).toEqual(["b", "c"]); + expect(oracle(rows, range)).toEqual(["b", "c"]); + }); + + it("leaves a workout's twins out of a baseline along with the workout", async () => { + const at = (m: number) => new Date(BASE + m * MIN); + const rows: Row[] = [ + { id: "old", startedAt: at(-600) }, + { id: "apple", startedAt: at(0) }, + { id: "withings", startedAt: at(1) }, + ].map((row, index) => ({ + ...row, + durationSec: 1800, + sportType: "running", + source: index === 2 ? "WITHINGS" : "APPLE_HEALTH", + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + })); + const windowed = await readWorkoutsAroundRange( + { from: at(-1000), to: null }, + memoryStore(rows).read, + ); + expect( + canonicalWorkoutsInRange(windowed, null, { + excludeClusterOf: "withings", + }).map((row) => row.id), + ).toEqual(["old"]); + }); +}); diff --git a/src/lib/workouts/batch-dedup.ts b/src/lib/workouts/batch-dedup.ts new file mode 100644 index 000000000..a7705123c --- /dev/null +++ b/src/lib/workouts/batch-dedup.ts @@ -0,0 +1,73 @@ +/** + * The write-time duplicate pick of `POST /api/workouts/batch`. + * + * The rows a batch is about to insert are clustered together with the stored + * workouts around them, read through the same windowed reader the list uses, + * so a cluster boundary falls where every read will later draw it. Clustering + * the batch alone could merge a row into a cluster the list then splits (a + * stored 09:58 run anchors a cluster with an incoming 10:02, which leaves an + * incoming 10:06 a session of its own) and drop a workout the list would have + * shown. + */ +import type { MeasurementSource, Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; + +import { + incomingWorkoutsToKeep, + readWorkoutsAroundRange, +} from "./canonical-read"; + +/** The indexes of the batch rows to write; the rest are duplicates. */ +export async function batchWorkoutIndicesToKeep( + userId: string, + prepared: ReadonlyArray<{ + index: number; + row: Prisma.WorkoutCreateManyInput; + }>, + priorityJson: unknown, +): Promise> { + if (prepared.length === 0) return new Set(); + const incoming = prepared.map((p) => ({ + index: p.index, + userId: p.row.userId, + sportType: p.row.sportType, + startedAt: p.row.startedAt as Date, + durationSec: p.row.durationSec, + // Zod's `measurementSourceEnum.optional().default("MANUAL")` guarantees + // `source` is set post-parse, but Prisma's `WorkoutCreateManyInput` types + // it optional. + source: (p.row.source ?? "MANUAL") as MeasurementSource, + avgHeartRate: p.row.avgHeartRate ?? null, + maxHeartRate: p.row.maxHeartRate ?? null, + metadata: p.row.metadata ?? null, + })); + const starts = incoming.map((row) => row.startedAt.getTime()); + const stored = await readWorkoutsAroundRange( + { + from: new Date(Math.min(...starts)), + to: new Date(Math.max(...starts)), + inclusiveEnd: true, + }, + (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + userId: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + }), + ); + return new Set( + incomingWorkoutsToKeep(stored, incoming, priorityJson).map( + (row) => row.index, + ), + ); +} diff --git a/src/lib/workouts/canonical-read.ts b/src/lib/workouts/canonical-read.ts new file mode 100644 index 000000000..055eae3bb --- /dev/null +++ b/src/lib/workouts/canonical-read.ts @@ -0,0 +1,400 @@ +/** + * The read every surface that decides which workout is canonical goes through. + * + * `pickCanonicalWorkoutRows` clusters workouts of one sport by start time, + * anchored on each cluster's first start (see `workoutDuplicateClusterKeys`). + * That makes the cluster of a workout depend on the workouts around it: on + * the twin a few minutes after it, which may lie past the end of the range a + * surface asked for, and on the run of workouts before it, which decides where + * the anchors fall. A surface that handed the picker only the rows inside its + * own range got an answer that depended on the range: the list and the detail + * of one workout disagreed about which row was canonical, and 09:58, 10:02 and + * 10:06 were grouped one way by a read starting at 09:55 and another way by a + * read starting at 10:00. + * + * `readWorkoutsAroundRange` reads the range plus one duplicate window after its + * end, and before its start as far as it takes to reach, for every sport with a + * row in the range, a workout that starts a full window after the one before + * it. Such a workout is an anchor whatever comes before it, so from there on + * the clusters are the ones a read of the whole history would produce. The + * surfaces then pick over everything read and keep the rows that start inside + * their range: a workout is canonical, or not, the same way on every surface. + * + * The reader is async and calls the callback before it first awaits, so in a + * `Promise.all` it goes after the plain Prisma calls: an element before it + * that threw while the array was being built would leave its rejection + * unhandled. + * + * A read callback may still carry a row cap as a runaway guard. A capped read + * that actually hits its cap has lost rows at one end and is outside this + * guarantee; the caps sit far above any real account's volume. + */ +import { + pickCanonicalWorkoutRows, + WORKOUT_DUPLICATE_WINDOW_MS, + workoutClusterGroup, + workoutDuplicateClusterKeys, + workoutSourceLadder, + type WorkoutPickerRow, +} from "@/lib/measurements/pick-canonical-workout-rows"; + +/** + * The columns a read needs for the canonical pick and nothing more: the + * session identity (`sportType`, `startedAt`, `durationSec`), the source the + * ladder ranks, and the id the windowed read orders by. + */ +export const WORKOUT_PICK_SELECT = { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, +} as const; + +/** A row the windowed read can order deterministically. */ +export type CanonicalReadRow = WorkoutPickerRow & { id: string }; + +/** + * The range of starts a surface asks about. `null` leaves that side open. + * `inclusiveEnd` makes `to` part of the range (`lte`), as the list's `until` + * and the doctor report's end are. + */ +export interface WorkoutStartRange { + from: Date | null; + to: Date | null; + inclusiveEnd?: boolean; +} + +/** The `startedAt` bounds one read callback must apply. */ +export interface WorkoutStartBounds { + gte?: Date; + lt?: Date; +} + +/** + * Reads the caller's workouts whose start lies inside `bounds`, with every + * other filter (user, sport) the caller applies. Called more than once when + * the read has to reach further back. + */ +export type WorkoutBoundedRead = ( + bounds: WorkoutStartBounds, +) => Promise; + +/** + * Rows read for a range, in `startedAt ASC, id ASC` order, together with the + * range they were read for. Only `readWorkoutsAroundRange` builds one, so a + * picker call below cannot be handed rows that were read without the margin. + */ +export interface WindowedWorkouts { + readonly rows: readonly T[]; + readonly range: WorkoutStartRange; + readonly __windowed: true; +} + +/** + * How many times the read reaches further back before it stops. Each step + * doubles the reach, so the last one covers more than a week of workouts + * every one of which started less than five minutes after the previous one. + */ +const MAX_BACKWARD_STEPS = 12; + +function compareRows(a: CanonicalReadRow, b: CanonicalReadRow): number { + const delta = a.startedAt.getTime() - b.startedAt.getTime(); + if (delta !== 0) return delta; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** + * The earliest start that is not yet known to be an anchor, among the sports + * that have a row at or after `from`. A sport's earliest row read is an anchor + * once the read reaches a full window before it (nothing of that sport starts + * inside that window, or the read would hold it). `null` when every sport is + * settled. + */ +function earliestUnsettledStart( + rows: readonly CanonicalReadRow[], + from: Date, + lowerMs: number, +): number | null { + const earliest = new Map(); + const relevant = new Set(); + for (const row of rows) { + const group = workoutClusterGroup(row); + const start = row.startedAt.getTime(); + const known = earliest.get(group); + if (known === undefined || start < known) earliest.set(group, start); + if (start >= from.getTime()) relevant.add(group); + } + let unsettled: number | null = null; + for (const group of relevant) { + const start = earliest.get(group)!; + if (start - WORKOUT_DUPLICATE_WINDOW_MS < lowerMs) { + unsettled = unsettled === null ? start : Math.min(unsettled, start); + } + } + return unsettled; +} + +export async function readWorkoutsAroundRange( + range: WorkoutStartRange, + read: WorkoutBoundedRead, +): Promise> { + const window = WORKOUT_DUPLICATE_WINDOW_MS; + // A cluster is never wider than one window from its anchor, and an anchor + // of the range starts no later than the range's end. + const upper = range.to ? new Date(range.to.getTime() + window) : undefined; + const from = range.from; + let lowerMs = from ? from.getTime() - window : null; + + const rows: T[] = [ + ...(await read({ + gte: lowerMs === null ? undefined : new Date(lowerMs), + lt: upper, + })), + ]; + + if (from && lowerMs !== null) { + for (let step = 0; step < MAX_BACKWARD_STEPS; step += 1) { + const unsettled = earliestUnsettledStart(rows, from, lowerMs); + if (unsettled === null) break; + const nextMs: number = Math.min( + unsettled - window, + lowerMs - window * 2 ** step, + ); + const earlier = await read({ + gte: new Date(nextMs), + lt: new Date(lowerMs), + }); + lowerMs = nextMs; + // Nothing between the new and the old bound: every unsettled sport now + // has a full window of nothing before its earliest row. + if (earlier.length === 0) break; + rows.push(...earlier); + } + } + + rows.sort(compareRows); + return { rows, range, __windowed: true }; +} + +/** Whether a start lies inside the range a surface asked about. */ +export function startsInRange(range: WorkoutStartRange, at: Date): boolean { + const t = at.getTime(); + if (range.from && t < range.from.getTime()) return false; + if (range.to) { + const end = range.to.getTime(); + if (range.inclusiveEnd ? t > end : t >= end) return false; + } + return true; +} + +/** The rows read that start inside the requested range. */ +export function rowsInRange( + windowed: WindowedWorkouts, +): T[] { + return windowed.rows.filter((row) => + startsInRange(windowed.range, row.startedAt), + ); +} + +function clusterKeyById( + windowed: WindowedWorkouts, +): Map { + const keys = workoutDuplicateClusterKeys(windowed.rows); + return new Map(windowed.rows.map((row, index) => [row.id, keys[index]!])); +} + +/** + * The canonical workouts that start inside the range, oldest first. + * + * `excludeClusterOf` leaves out the cluster of one workout: a baseline that + * compares a session against the others of its sport must drop the session's + * twins along with the session itself. Removing the row from the read instead + * left its twin behind as a second session, and moved the cluster anchors of + * whatever followed. + */ +export function canonicalWorkoutsInRange( + windowed: WindowedWorkouts, + priorityJson: unknown, + options: { excludeClusterOf?: string } = {}, +): T[] { + const picked = pickCanonicalWorkoutRows(windowed.rows, priorityJson).filter( + (row) => startsInRange(windowed.range, row.startedAt), + ); + if (!options.excludeClusterOf) return picked; + const keyById = clusterKeyById(windowed); + const excluded = keyById.get(options.excludeClusterOf); + if (excluded === undefined) return picked; + return picked.filter((row) => keyById.get(row.id) !== excluded); +} + +/** + * The duplicate cluster of one workout: its rows in start order, and the + * canonical ones among them, nearest to the workout's own start first. `null` + * when the workout is not among the rows read. + */ +export function canonicalClusterOf( + windowed: WindowedWorkouts, + workoutId: string, + priorityJson: unknown, +): { members: T[]; canonical: T[] } | null { + const self = windowed.rows.find((row) => row.id === workoutId); + if (!self) return null; + const keyById = clusterKeyById(windowed); + const key = keyById.get(workoutId); + const members = windowed.rows.filter((row) => keyById.get(row.id) === key); + const at = self.startedAt.getTime(); + const canonical = pickCanonicalWorkoutRows(members, priorityJson).sort( + (a, b) => + Number(b.id === workoutId) - Number(a.id === workoutId) || + Math.abs(a.startedAt.getTime() - at) - + Math.abs(b.startedAt.getTime() - at) || + compareRows(a, b), + ); + return { members, canonical }; +} + +/** + * The write-time pick of the batch route. The incoming rows are clustered + * together with the stored workouts around them, so a cluster boundary falls + * where every read will later draw it. An incoming row is dropped only when + * another incoming row of its cluster is the one kept: a row that loses to a + * stored workout is still written (the reads hide it), as before. + * + * Returns the incoming rows to keep, in their input order. + */ +export function incomingWorkoutsToKeep< + S extends CanonicalReadRow, + I extends WorkoutPickerRow, +>( + stored: WindowedWorkouts, + incoming: readonly I[], + priorityJson: unknown, +): I[] { + type Tagged = WorkoutPickerRow & { tag: number }; + // Stored rows first on an equal start, which is where a read that orders by + // start finds them relative to rows written after them. + const union: Tagged[] = [ + ...stored.rows.map((row) => ({ ...row, tag: -1 })), + ...incoming.map((row, index) => ({ ...row, tag: index })), + ].sort( + (a, b) => + a.startedAt.getTime() - b.startedAt.getTime() || + Number(a.tag >= 0) - Number(b.tag >= 0) || + a.tag - b.tag, + ); + const keys = workoutDuplicateClusterKeys(union); + const keyByTag = new Map(); + union.forEach((row, index) => { + if (row.tag >= 0) keyByTag.set(row.tag, keys[index]!); + }); + const keptTags = new Set( + pickCanonicalWorkoutRows(union, priorityJson) + .map((row) => row.tag) + .filter((tag) => tag >= 0), + ); + const clustersWithIncomingWinner = new Set( + [...keptTags].map((tag) => keyByTag.get(tag)!), + ); + return incoming.filter( + (_, index) => + keptTags.has(index) || + !clustersWithIncomingWinner.has(keyByTag.get(index)!), + ); +} + +/** + * One training impulse per workout SESSION, not per stored row. + * + * The same session can be stored more than once: the watch writes it to Apple + * Health and a second service (Strava, WHOOP, a ring) syncs it again, or + * HealthKit re-sends it under a new identifier. The workout list collapses + * those through `pickCanonicalWorkoutRows`; summing every row here counted the + * session twice in the day's TRIMP while the list showed it once. + * + * The picker chooses the row by source order, which says nothing about heart + * rate: the winning row can be one without a series while its dropped twin + * carries one. So the impulse of a slot is the sum over its picked rows, and + * only when that is zero the largest impulse among the slot's dropped rows. + * + * The picker keeps every row of a slot none of whose sources is on the + * ladder, so the picked rows of one slot can come from several sources. Only + * one source's rows count: the best-ranked on the ladder, a source off the + * ladder ranking below every listed one, and among sources off the ladder + * the one with the largest impulse. Rows of that one source still add up + * (two distinct sessions of the same sport in one duplicate cluster). + * + * `belongs` keeps the sessions of the scored range: the rows come from a + * windowed read that reaches past the day on both sides, and a session counts + * on the day its earliest picked row starts, the day the list shows it on. + * Returns the positive impulses, one per session. Pure; the strain + * engine's day TRIMP sums it, and the tests call it directly. + */ +export function canonicalWorkoutTrimps< + T extends WorkoutPickerRow & { id: string }, +>( + rows: readonly T[], + priorityJson: unknown, + trimpOf: (row: T) => number, + belongs: (startedAt: Date) => boolean = () => true, +): number[] { + // By id: the picker may hand back a copy of a row (it fills a missing heart + // rate from a WHOOP twin). + const picked = new Set( + pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), + ); + const ladder = workoutSourceLadder(priorityJson); + const rankOf = (source: string) => { + const index = ladder.indexOf(source as (typeof ladder)[number]); + return index === -1 ? ladder.length : index; + }; + const slots = new Map< + string, + { + picked: Map; + dropped: number; + firstPickedAt: Date | null; + } + >(); + const keys = workoutDuplicateClusterKeys(rows); + for (const [index, row] of rows.entries()) { + const slot = slots.get(keys[index]!) ?? { + picked: new Map(), + dropped: 0, + firstPickedAt: null, + }; + if ( + picked.has(row.id) && + (slot.firstPickedAt === null || row.startedAt < slot.firstPickedAt) + ) { + slot.firstPickedAt = row.startedAt; + } + const trimp = trimpOf(row); + if (trimp > 0) { + if (picked.has(row.id)) { + slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp); + } else { + slot.dropped = Math.max(slot.dropped, trimp); + } + } + slots.set(keys[index]!, slot); + } + const out: number[] = []; + for (const slot of slots.values()) { + if (slot.firstPickedAt === null || !belongs(slot.firstPickedAt)) continue; + let best: { rank: number; trimp: number } | null = null; + for (const [source, trimp] of slot.picked) { + const rank = rankOf(source); + if ( + best === null || + rank < best.rank || + (rank === best.rank && trimp > best.trimp) + ) { + best = { rank, trimp }; + } + } + const trimp = best !== null ? best.trimp : slot.dropped; + if (trimp > 0) out.push(trimp); + } + return out; +} diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts index c61e11d9c..5c3181c56 100644 --- a/src/lib/workouts/list-read.ts +++ b/src/lib/workouts/list-read.ts @@ -18,7 +18,11 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + rowsInRange, +} from "@/lib/workouts/canonical-read"; import { cached, caches } from "@/lib/cache/server-cache"; import { workoutSportTypeEnum } from "@/lib/validations/workout"; import { isoDateTime } from "@/lib/validations/datetime"; @@ -131,62 +135,63 @@ export async function buildWorkoutsProjection( ): Promise { const { since, until, sportType } = params; - const where: Record = { userId }; - if (since || until) { - const range: Record = {}; - if (since) { - const d = new Date(since); - if (!Number.isNaN(d.getTime())) range.gte = d; - } - if (until) { - const d = new Date(until); - if (!Number.isNaN(d.getTime())) range.lte = d; - } - if (Object.keys(range).length > 0) { - where.startedAt = range; - } - } - if (sportType) { - where.sportType = sportType; - } + const parse = (value: string | null): Date | null => { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + }; + const range = { + from: parse(since), + to: parse(until), + inclusiveEnd: true, + }; // Independent reads — the sourcePriorityJson lookup doesn't depend on the // row read, so run them concurrently rather than paying two sequential - // round trips. - const [rows, userRow] = await Promise.all([ - prisma.workout.findMany({ - where, - orderBy: [{ startedAt: "desc" }, { id: "asc" }], - select: { - id: true, - source: true, - externalId: true, - sportType: true, - startedAt: true, - endedAt: true, - durationSec: true, - totalDistanceM: true, - totalEnergyKcal: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - createdAt: true, - route: { select: { id: true } }, - samples: { select: { sampleCount: true } }, - }, - }), + // round trips. The rows come through the windowed reader: a twin just past + // `until`, or a cluster that began just before `since`, decides which row + // of the range is canonical exactly as it does for the detail route. + const [userRow, around] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, }), + readWorkoutsAroundRange(range, (bounds) => + prisma.workout.findMany({ + where: { + userId, + startedAt: bounds, + ...(sportType ? { sportType } : {}), + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + externalId: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + createdAt: true, + route: { select: { id: true } }, + samples: { select: { sampleCount: true } }, + }, + }), + ), ]); - const canonical = pickCanonicalWorkoutRows( - rows, + const canonical: CanonicalWorkoutRow[] = canonicalWorkoutsInRange( + around, userRow?.sourcePriorityJson ?? null, ); + const rawCount = rowsInRange(around).length; - // `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its + // The canonical pick comes back in `startedAt ASC` order (its // documented contract for the rollup callers). This surface is a // most-recent-first list, so the DB `orderBy startedAt desc` must be // re-established AFTER the pick — otherwise `offset`/`limit` slice the @@ -197,7 +202,7 @@ export async function buildWorkoutsProjection( return a.id.localeCompare(b.id); }); - return { canonical, rawCount: rows.length }; + return { canonical, rawCount }; } export function sliceWorkoutsProjection( diff --git a/src/lib/workouts/sport-context.ts b/src/lib/workouts/sport-context.ts index 9e16828d0..f97f69e4d 100644 --- a/src/lib/workouts/sport-context.ts +++ b/src/lib/workouts/sport-context.ts @@ -18,7 +18,10 @@ import { type CoachHistoryReach, } from "@/lib/ai/coach/history-reach"; import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; /** Own-history lookback for the sport-average comparison line. */ const SPORT_CONTEXT_LOOKBACK_DAYS = 180; @@ -47,28 +50,32 @@ export async function buildSportContext( const since = new Date( Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, ); - const rows = await prisma.workout.findMany({ - // `userId` stays in the where clause — the universal tenancy narrow. The - // selected workout is not part of the baseline it is compared against. - where: { - userId, - sportType, - startedAt: { gte: since }, - ...(excludeWorkoutId ? { id: { not: excludeWorkoutId } } : {}), - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - take: SPORT_CONTEXT_MAX_ROWS, - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - durationSec: true, - totalDistanceM: true, - avgHeartRate: true, - }, + // `userId` stays in the where clause — the universal tenancy narrow. The + // selected workout is not part of the baseline it is compared against, and + // neither are its twins: the selected row stays in the read so its whole + // duplicate cluster can be left out after the pick. Dropping only the row in + // SQL left a twin from a second source behind as a session of its own. + const rows = await readWorkoutsAroundRange( + { from: since, to: null }, + (bounds) => + prisma.workout.findMany({ + where: { userId, sportType, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: SPORT_CONTEXT_MAX_ROWS, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + durationSec: true, + totalDistanceM: true, + avgHeartRate: true, + }, + }), + ); + const canonical = canonicalWorkoutsInRange(rows, sourcePriorityJson, { + excludeClusterOf: excludeWorkoutId, }); - const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson); if (canonical.length === 0) return null; let durationSum = 0; diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index b217a23f5..48a526e97 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -12,7 +12,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -26,12 +26,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -75,7 +75,7 @@ "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", "insuranceNumber": null, - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber", "lastReportPracticeName": "rt-User-lastReportPracticeName", "avatarBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", @@ -83,7 +83,7 @@ "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "timezone": "America/Chicago", "locale": "fr", @@ -196,8 +196,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -219,8 +219,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -242,8 +242,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -255,7 +255,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": "rt-Measurement-notes", - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -265,8 +265,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" }, { "id": "", @@ -288,17 +288,19 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" } ], "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -328,7 +330,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -336,7 +339,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "id": "", @@ -381,8 +384,8 @@ { "id": "", "notes": "rt-MedicationSideEffect-notes", - "notesEncrypted": "", - "createdAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z", "occurredAt": "2026-07-01T21:00:00.000Z", "category": "GI", "entry": "NAUSEA", @@ -394,50 +397,55 @@ "id": "", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", - "createdAt": "" + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ { "id": "", "note": "rt-MedicationDoseChange-note", - "noteEncrypted": "", + "noteEncrypted": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -446,26 +454,26 @@ "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ { "id": "", "notes": "rt-MedicationInventoryItem-notes", - "notesEncrypted": "", + "notesEncrypted": "", "state": "ACTIVE", "containerType": "BLISTER", "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "id": "", @@ -481,8 +489,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -524,7 +532,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "id": "", @@ -539,7 +547,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "id": "", @@ -554,14 +562,14 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ { "id": "", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z", "measurementType": "WEIGHT", "biomarkerName": "Ferritin", "primary": false @@ -573,17 +581,38 @@ { "id": "", "medicationId": "", + "autoMissed": false, + "attributionSource": "AUTO", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "", + "medication": "Round-trip tablet", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": false + }, + { + "id": "", + "medicationId": "", "autoMissed": true, "attributionSource": "USER_PIN", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null, "medication": "Round-trip tablet", @@ -595,14 +624,47 @@ } ], "moodEntries": [ + { + "note": null, + "noteEncrypted": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "syncVersion": 0, + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], + "id": "", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "a1": null, + "a2": null, + "a3": null, + "a4": null, + "a5": null, + "tags": null, + "source": "MOODLOG", + "loggedAt": "2026-07-03T20:00:00.000Z", + "externalId": null, + "tz": null, + "deletedAt": "", + "factors": [], + "structuredTags": [], + "context": null + }, { "note": "rt-MoodEntry-note", - "noteEncrypted": "", - "syncedAt": "", + "noteEncrypted": "", + "syncedAt": "2026-05-02T04:17:00.000Z", "syncVersion": 3, - "createdAt": "", - "updatedAt": "", - "id": "", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], + "id": "", "date": "2026-07-01", "mood": "GUT", "score": 4, @@ -625,7 +687,9 @@ ], "structuredTags": [], "context": { - "notesEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -648,41 +712,41 @@ ], "customMoodTagCategories": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ { - "id": "", + "id": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", - "categoryId": "", + "categoryId": "", "kind": "RATED", "isActive": false, "icon": "rt-MoodTag-icon", "sortOrder": 3, - "labelEncrypted": "", + "labelEncrypted": "", "scaleMin": 3, "scaleMax": 3, "inverse": true } ], "cycleProfile": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, "typicalCycleLength": 3, @@ -696,13 +760,29 @@ }, "cycles": [ { - "id": "", + "id": "", + "isPredicted": false, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "tz": null + }, + { + "id": "", "isPredicted": true, "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", "periodEndDate": "rt-MenstrualCycle-periodEndDate", @@ -712,13 +792,13 @@ "tz": "rt-MenstrualCycle-tz" }, { - "id": "", + "id": "", "isPredicted": false, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "", + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z", "startDate": "2026-06-03", "endDate": null, "periodEndDate": null, @@ -730,12 +810,12 @@ ], "cycleDayLogs": [ { - "id": "", - "cycleId": "", + "id": "", + "cycleId": "", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z", "date": "2026-06-02", "flow": "HEAVY", "intermenstrualBleeding": true, @@ -751,8 +831,8 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", @@ -764,28 +844,65 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] + }, + { + "id": "", + "cycleId": "", + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z", + "date": "2026-06-03", + "flow": "LIGHT", + "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, + "symptomKeys": [], + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ { - "id": "", + "id": "", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", "categoryId": "csc_physical", "icon": "rt-CycleSymptom-icon", "sortOrder": 3, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "labResults": [ { - "id": "", - "biomarkerId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "biomarkerId": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null, "note": null, "panel": "rt-LabResult-panel", @@ -803,45 +920,88 @@ "takenAt": "2026-06-30T09:00:00.000Z", "source": "rt-LabResult-source", "biomarkerName": "Ferritin" + }, + { + "id": "", + "biomarkerId": null, + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "", + "note": null, + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "source": "MANUAL", + "biomarkerName": null } ], "biomarkers": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { "name": "Ferr.", "source": "IMPORT_CHOICE", - "id": "", - "createdAt": "" + "id": "", + "createdAt": "2026-05-06T11:26:00.000Z" } ] } ], "biomarkerMerges": [ { - "id": "", - "targetBiomarkerId": "", + "id": "", + "targetBiomarkerId": "", "targetName": "Ferritin", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { - "id": "", + "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "deletedAt": null, + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -849,37 +1009,70 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null, "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null, "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] + }, + { + "id": "", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "note": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "", + "symptoms": [] } ] }, { - "id": "", + "id": "", + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "", + "dayLogs": [] + }, + { + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -889,17 +1082,33 @@ "note": null, "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null, "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reaction": null, + "note": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -908,29 +1117,42 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": null, "note": null, - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "familyHistory": [ { - "id": "", + "id": "", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", + "deletedAt": null } ], "workouts": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z", "metadata": { "roundTrip": "rt-Workout-metadata" }, @@ -953,7 +1175,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -965,14 +1187,14 @@ "sourceSystem": "PAPERLESS", "sourceId": "4711", "sourceInstance": "https://paperless.example.test", - "createdAt": "", - "updatedAt": "", - "contentEncrypted": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "providerType": "rt-InboundDocument-providerType", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -999,7 +1221,7 @@ } }, "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -1016,35 +1238,46 @@ "emergencyContacts": null, "emergencyImplants": null, "emergencyNote": null, - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", - "createdAt": "", - "updatedAt": "" + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "kind": "SMOKING_STATUS", + "value": null, + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "customMetrics": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, "name": "Grip strength", "unit": "kg", @@ -1055,8 +1288,18 @@ "correlationEnabled": true, "entries": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "", + "noteEncrypted": null, + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null + }, + { + "id": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null, "noteEncrypted": null, "value": 44, @@ -1065,23 +1308,37 @@ "note": "left hand, after the long ride" }, { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null, - "noteEncrypted": "", + "noteEncrypted": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null } ] + }, + { + "id": "", + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "entries": [] } ], "correlationPatterns": [ { - "id": "", - "updatedAt": "", - "createdAt": "", + "id": "", + "updatedAt": "2026-05-03T18:39:00.000Z", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -1103,9 +1360,9 @@ "decryptFailures": [], "intradayProfiles": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z", "type": "ACTIVITY_STEPS", "dateKey": "2026-07-01", "hourlyCumulative": [ @@ -1169,8 +1426,8 @@ ], "healthScoreRecords": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T20:13:00.000Z", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", "composite": 71, @@ -1187,13 +1444,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z", "needs": { "recordTarget": "me", "areas": [ @@ -1258,61 +1515,102 @@ }, "practitioners": [ { - "id": "", + "id": "", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -1320,14 +1618,14 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z", "deletedAt": null } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -1335,26 +1633,44 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -1371,46 +1687,68 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1421,35 +1759,35 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" } ], "attachments": [] }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1462,7 +1800,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -1470,61 +1808,124 @@ ], "coachFacts": [ { - "id": "", - "factEncrypted": "", + "id": "", + "factEncrypted": "", "deletedAt": null, "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" + }, + { + "id": "", + "factEncrypted": "", + "deletedAt": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null, + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", - "outcomeEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", + "outcomeEncrypted": "", "deletedAt": null, "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" + }, + { + "id": "", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "outcomeEncrypted": null, + "deletedAt": "", + "status": "active", + "reviewDate": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z" } ], "coachReminders": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "deletedAt": null, "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" + }, + { + "id": "", + "noteEncrypted": "", + "deletedAt": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z" } ], "mentalHealthAssessments": [ { - "id": "", + "id": "", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -1535,49 +1936,49 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "committedRecordId": "", + "committedRecordId": "", "committedRecordType": "labResult", - "dataEncrypted": "", - "provenanceEncrypted": "", - "createdAt": "", - "updatedAt": "" + "dataEncrypted": "", + "provenanceEncrypted": "", + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "personalRecords": [ @@ -1591,7 +1992,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -1603,7 +2004,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -1615,19 +2016,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -1635,7 +2036,7 @@ "date": "2026-06-15", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "TRAVEL", "tempMin": 19.4, @@ -1673,14 +2074,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "HOME", "tempMin": 13.1, @@ -1718,8 +2119,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -1729,9 +2130,9 @@ "lat": null, "lon": null, "label": null, - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -1743,17 +2144,17 @@ "placesVisited": 4, "awayFromHomeMinutes": 1440, "countryCode": null, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ { - "id": "", - "waveformEncrypted": "", + "id": "", + "waveformEncrypted": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", @@ -1764,120 +2165,132 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } ], "lifeEvents": [ { - "id": "", - "titleEncrypted": "", - "noteEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": "", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": null, + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "titleEncrypted": "", "noteEncrypted": null, "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "labResultDocumentLinks": [ { - "labResultId": "", - "documentId": "", - "createdAt": "" + "labResultId": "", + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -1888,14 +2301,15 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] }, "builtCounts": { "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationPauseEras": 2, "medicationDoseChanges": 2, @@ -1904,43 +2318,43 @@ "reminderPhaseConfigs": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, + "moodEntries": 2, "customMoodTagCategories": 1, "hiddenMoodTags": 1, - "cycles": 2, - "cycleDayLogs": 1, + "cycles": 3, + "cycleDayLogs": 2, "nutrientDays": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, - "customMetrics": 1, - "healthProfileFactRevisions": 1, - "customMetricEntries": 2, + "customMetrics": 2, + "healthProfileFactRevisions": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "customVaccines": 1, - "vaccinations": 1, + "customVaccines": 2, + "vaccinations": 2, "vaccinationLinks": 1, - "measurementReminders": 1, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, "coachConversationDocuments": 1, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "documentConditionLinks": 1, "extractedFacts": 1, @@ -1952,13 +2366,13 @@ "ecgRecordings": 1, "symptomDefinitions": 2, "symptomEvents": 3, - "lifeEvents": 2, + "lifeEvents": 3, "relationLinks": 5 }, "streamedCounts": { "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationPauseEras": 2, "medicationDoseChanges": 2, @@ -1967,43 +2381,43 @@ "reminderPhaseConfigs": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, + "moodEntries": 2, "customMoodTagCategories": 1, "hiddenMoodTags": 1, - "cycles": 2, - "cycleDayLogs": 1, + "cycles": 3, + "cycleDayLogs": 2, "nutrientDays": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, - "customMetrics": 1, - "healthProfileFactRevisions": 1, - "customMetricEntries": 2, + "customMetrics": 2, + "healthProfileFactRevisions": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "customVaccines": 1, - "vaccinations": 1, + "customVaccines": 2, + "vaccinations": 2, "vaccinationLinks": 1, - "measurementReminders": 1, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, "coachConversationDocuments": 1, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "documentConditionLinks": 1, "extractedFacts": 1, @@ -2015,7 +2429,7 @@ "ecgRecordings": 1, "symptomDefinitions": 2, "symptomEvents": 3, - "lifeEvents": 2, + "lifeEvents": 3, "relationLinks": 5 }, "parsed": { @@ -2031,7 +2445,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2042,12 +2456,12 @@ "glitchtipEnvironment": "rt-AppSettings-glitchtipEnvironment", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2094,7 +2508,7 @@ "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", "insuranceNumber": null, - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber", "lastReportPracticeName": "rt-User-lastReportPracticeName", "avatarBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", @@ -2102,7 +2516,7 @@ "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "timezone": "America/Chicago", "locale": "fr", @@ -2215,8 +2629,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -2238,8 +2652,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -2261,8 +2675,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -2274,7 +2688,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": "rt-Measurement-notes", - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -2284,8 +2698,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" }, { "id": "", @@ -2307,8 +2721,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" } ], "medications": [ @@ -2331,6 +2745,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "deliveryForm": "INJECTION", "trackInjectionSites": true, "allowedInjectionSites": [ @@ -2345,8 +2760,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", + "updatedAt": "2026-05-01T11:51:00.000Z", "schedules": [ { "id": "", @@ -2395,8 +2810,8 @@ "entry": "NAUSEA", "severity": 3, "notes": "rt-MedicationSideEffect-notes", - "notesEncrypted": "", - "createdAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z" } ], "pauseEras": [ @@ -2404,13 +2819,13 @@ "id": "", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -2422,7 +2837,8 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z", + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "doseChanges": [ @@ -2432,8 +2848,8 @@ "doseValue": 5, "doseUnit": "mg", "note": "rt-MedicationDoseChange-note", - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -2442,7 +2858,7 @@ "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -2453,15 +2869,15 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": "rt-MedicationInventoryItem-notes", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "id": "", @@ -2477,8 +2893,8 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "inventoryEvents": [ @@ -2512,8 +2928,8 @@ "measurementType": "WEIGHT", "biomarkerName": "Ferritin", "primary": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z" } ], "scheduleRevisions": [ @@ -2530,7 +2946,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "id": "", @@ -2545,7 +2961,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "id": "", @@ -2560,21 +2976,25 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "courses": [ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", - "createdAt": "" + "note": null, + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", + "updatedAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "noteEncrypted": null, + "createdAt": "2026-05-02T00:46:01.000Z", + "updatedAt": "2026-05-02T00:47:01.000Z" } ] } @@ -2582,10 +3002,12 @@ "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "intakeEvents": [ @@ -2593,6 +3015,27 @@ "id": "", "medicationId": "", "medication": "Round-trip tablet", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", + "source": "WEB", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "" + }, + { + "id": "", + "medicationId": "", + "medication": "Round-trip tablet", "scheduledFor": "2026-07-01T08:00:00.000Z", "takenAt": "2026-07-01T08:04:00.000Z", "skipped": true, @@ -2601,21 +3044,48 @@ "attributionSource": "USER_PIN", "source": "API", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null } ], "moodEntries": [ { - "id": "", + "id": "", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "a1": null, + "a2": null, + "a3": null, + "a4": null, + "a5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MOODLOG", + "externalId": null, + "loggedAt": "2026-07-03T20:00:00.000Z", + "tz": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "factors": [], + "structuredTags": [], + "linkCreatedAt": [], + "context": null + }, + { + "id": "", "date": "2026-07-01", "mood": "GUT", "score": 4, @@ -2626,16 +3096,16 @@ "a5": 3, "tags": "[\"round-trip-tag\"]", "note": "rt-MoodEntry-note", - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "loggedAt": "2026-07-01T20:00:00.000Z", "tz": "rt-MoodEntry-tz", - "syncedAt": "", + "syncedAt": "2026-05-02T04:17:00.000Z", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", "factors": [ { "key": "round_trip_factor", @@ -2643,6 +3113,12 @@ } ], "structuredTags": [], + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], "context": { "workStatus": "regular", "workMinutes": 3, @@ -2661,12 +3137,14 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "" + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } } ], "cycleProfile": { - "id": "", + "id": "", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, "typicalCycleLength": 3, @@ -2677,12 +3155,28 @@ "rawChartMode": true, "discreetNotifications": true, "sensitiveCategoryEncryption": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z" }, "cycles": [ { - "id": "", + "id": "", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z" + }, + { + "id": "", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", "periodEndDate": "rt-MenstrualCycle-periodEndDate", @@ -2694,11 +3188,11 @@ "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z" }, { - "id": "", + "id": "", "startDate": "2026-06-03", "endDate": null, "periodEndDate": null, @@ -2709,16 +3203,16 @@ "tz": null, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "" + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z" } ], "cycleDayLogs": [ { - "id": "", + "id": "", "date": "2026-06-02", - "cycleId": "", + "cycleId": "", "flow": "HEAVY", "intermenstrualBleeding": true, "basalBodyTempC": 2.5, @@ -2733,15 +3227,15 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z", "symptomKeys": [ "custom:round-trip-cramps" ], @@ -2750,49 +3244,86 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] + }, + { + "id": "", + "date": "2026-06-03", + "cycleId": "", + "flow": "LIGHT", + "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": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z", + "symptomKeys": [], + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ { - "id": "", + "id": "", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", "categoryId": "csc_physical", "icon": "rt-CycleSymptom-icon", "sortOrder": 3, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "customMoodTagCategories": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ { - "id": "", + "id": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", - "categoryId": "", + "categoryId": "", "kind": "RATED", "isActive": false, "icon": "rt-MoodTag-icon", "sortOrder": 3, - "labelEncrypted": "", + "labelEncrypted": "", "scaleMin": 3, "scaleMax": 3, "inverse": true @@ -2800,7 +3331,7 @@ ], "labResults": [ { - "id": "", + "id": "", "panel": "rt-LabResult-panel", "analyte": "Ferritin", "value": 91, @@ -2816,17 +3347,41 @@ "takenAt": "2026-06-30T09:00:00.000Z", "source": "rt-LabResult-source", "biomarkerName": "Ferritin", - "biomarkerId": "", + "biomarkerId": "", "note": null, - "createdAt": "", - "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "noteEncrypted": "", "deletedAt": null, - "updatedAt": "" + "updatedAt": "2026-05-02T10:59:00.000Z" + }, + { + "id": "", + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "source": "MANUAL", + "biomarkerName": null, + "biomarkerId": null, + "note": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "noteEncrypted": null, + "deletedAt": "", + "updatedAt": "2026-05-02T10:59:01.000Z" } ], "biomarkers": [ { - "id": "", + "id": "", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, @@ -2834,33 +3389,52 @@ "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "aliases": [ { - "id": "", + "id": "", "name": "Ferr.", "source": "IMPORT_CHOICE", - "createdAt": "" + "createdAt": "2026-05-06T11:26:00.000Z" } ], - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z" } ], "biomarkerMerges": [ { - "id": "", - "targetBiomarkerId": "", + "id": "", + "targetBiomarkerId": "", "targetName": "Ferritin", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { - "id": "", + "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "deletedAt": null, + "updatedAt": "2026-05-02T17:32:03.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -2868,37 +3442,70 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": null, - "createdAt": "", - "noteEncrypted": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, "deletedAt": null, - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ], "note": null, - "updatedAt": "", - "noteEncrypted": "", + "updatedAt": "2026-05-02T19:09:00.000Z", + "noteEncrypted": "", "tz": "rt-IllnessDayLog-tz", - "createdAt": "", + "createdAt": "2026-05-02T19:08:00.000Z", "deletedAt": null + }, + { + "id": "", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "symptoms": [], + "note": null, + "updatedAt": "2026-05-02T19:09:01.000Z", + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "deletedAt": "" } ] }, { - "id": "", + "id": "", + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, + "note": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "deletedAt": "", + "updatedAt": "2026-05-02T17:32:02.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -2906,96 +3513,124 @@ "resolvedAt": null, "parentConditionId": null, "note": null, - "createdAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", "deletedAt": null, - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], "symptomDefinitions": [ { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "noteEncrypted": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "noteEncrypted": null, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", + "id": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, - "noteEncrypted": "", + "noteEncrypted": "", "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } ], "lifeEvents": [ { - "id": "", + "id": "", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "titleEncrypted": "", + "noteEncrypted": null, + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "allergies": [ { - "id": "", + "id": "", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reaction": null, + "note": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "reactionEncrypted": null, + "notesEncrypted": null, + "deletedAt": "", + "updatedAt": "2026-05-02T14:11:01.000Z" + }, + { + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -3004,27 +3639,40 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": null, "note": null, - "createdAt": "", - "reactionEncrypted": "", - "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "reactionEncrypted": "", + "notesEncrypted": "", "deletedAt": null, - "updatedAt": "" + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", + "deletedAt": null } ], "workouts": [ { - "id": "", + "id": "", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", "endedAt": "2026-06-29T06:30:00.000Z", @@ -3043,13 +3691,13 @@ "metadata": { "roundTrip": "rt-Workout-metadata" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z" } ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -3058,12 +3706,12 @@ "status": "EXTRACTING", "reportDate": "2026-06-30T12:34:56.789Z", "documentDate": "2026-06-30T12:34:56.789Z", - "contentEncrypted": "", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "providerType": "rt-InboundDocument-providerType", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -3072,31 +3720,31 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "aiReadDeferred": true, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "committedRecordId": "", + "committedRecordId": "", "committedRecordType": "labResult", - "dataEncrypted": "", - "provenanceEncrypted": "", - "createdAt": "", - "updatedAt": "" + "dataEncrypted": "", + "provenanceEncrypted": "", + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "nutrientDays": [ @@ -3107,11 +3755,12 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -3128,33 +3777,44 @@ "emergencyContacts": null, "emergencyImplants": null, "emergencyNote": null, - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", - "createdAt": "", - "updatedAt": "" + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "kind": "SMOKING_STATUS", + "value": null, + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "customMetrics": [ { - "id": "", + "id": "", "name": "Grip strength", "unit": "kg", "targetLow": 2.5, @@ -3162,36 +3822,60 @@ "decimals": 3, "description": "rt-CustomMetric-description", "correlationEnabled": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, "entries": [ { - "id": "", + "id": "", + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null, + "noteEncrypted": null, + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "" + }, + { + "id": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": "left hand, after the long ride", "noteEncrypted": null, - "createdAt": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null } ] + }, + { + "id": "", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "", + "entries": [] } ], "correlationPatterns": [ { - "id": "", + "id": "", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -3208,13 +3892,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T18:38:00.000Z", + "updatedAt": "2026-05-03T18:39:00.000Z" } ], "intradayProfiles": [ { - "id": "", + "id": "", "type": "ACTIVITY_STEPS", "dateKey": "2026-07-01", "hourlyCumulative": [ @@ -3274,13 +3958,13 @@ "basis": "HOUR_BUCKETS", "bucketCount": 3, "timezone": "Europe/Berlin", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z" } ], "healthScoreRecords": [ { - "id": "", + "id": "", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", "composite": 71, @@ -3297,12 +3981,12 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", - "createdAt": "" + "computedAt": "2026-05-03T20:12:00.000Z", + "createdAt": "2026-05-03T20:13:00.000Z" } ], "onboardingRecord": { - "id": "", + "id": "", "needs": { "recordTarget": "me", "areas": [ @@ -3364,66 +4048,107 @@ }, "modulesDerivedAt": "2026-07-01T08:00:00.000Z", "completedAt": "2026-07-01T08:00:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z" }, "practitioners": [ { - "id": "", + "id": "", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -3431,14 +4156,14 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z", "deletedAt": null } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -3446,73 +4171,91 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "labResultDocumentLinks": [ { - "labResultId": "", - "documentId": "", - "createdAt": "" + "labResultId": "", + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -3523,35 +4266,35 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" } ], "attachments": [] }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -3564,7 +4307,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -3572,11 +4315,30 @@ ], "mentalHealthAssessments": [ { - "id": "", + "id": "", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -3587,81 +4349,125 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "coachFacts": [ { - "id": "", - "factEncrypted": "", + "id": "", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "", + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null + }, + { + "id": "", + "factEncrypted": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null, + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z", + "deletedAt": "" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", - "outcomeEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", + "outcomeEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null + }, + { + "id": "", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "outcomeEncrypted": null, + "status": "active", + "reviewDate": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z", + "deletedAt": "" } ], "coachReminders": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null + }, + { + "id": "", + "noteEncrypted": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z", + "deletedAt": "" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -3678,29 +4484,51 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "personalRecords": [ @@ -3714,7 +4542,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -3726,7 +4554,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -3738,19 +4566,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -3759,7 +4587,7 @@ "lat": null, "lon": null, "locationLabel": null, - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -3796,15 +4624,15 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", "lat": null, "lon": null, "locationLabel": null, - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -3841,8 +4669,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -3852,9 +4680,9 @@ "lat": null, "lon": null, "label": null, - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -3866,20 +4694,20 @@ "placesVisited": 4, "awayFromHomeMinutes": 1440, "countryCode": null, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ { - "id": "", + "id": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -3887,8 +4715,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "manifest": { @@ -3917,47 +4745,47 @@ "exportedAt": "2026-08-01T00:00:00.000Z", "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, - "cycles": 2, - "cycleDayLogs": 1, + "moodEntries": 2, + "cycles": 3, + "cycleDayLogs": 2, "nutrientDays": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, "extractedFacts": 1, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, - "customMetricEntries": 2, + "healthProfileFactRevisions": 2, + "customMetrics": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 1, "relationLinks": 5, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "personalRecords": 3, "userAchievements": 2, @@ -3973,47 +4801,47 @@ "exportedAt": "2026-08-01T00:00:00.000Z", "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, - "cycles": 2, - "cycleDayLogs": 1, - "labResults": 1, + "moodEntries": 2, + "cycles": 3, + "cycleDayLogs": 2, + "labResults": 2, "nutrientDays": 1, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, "extractedFacts": 1, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, - "customMetricEntries": 2, + "healthProfileFactRevisions": 2, + "customMetrics": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 1, "relationLinks": 5, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "personalRecords": 3, "userAchievements": 2, diff --git a/tests/integration/__golden__/backup-parity/export-portable.json b/tests/integration/__golden__/backup-parity/export-portable.json index 35ffc7afc..b7076c404 100644 --- a/tests/integration/__golden__/backup-parity/export-portable.json +++ b/tests/integration/__golden__/backup-parity/export-portable.json @@ -162,7 +162,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "medications": [ @@ -175,7 +175,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -203,12 +203,12 @@ { "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -219,7 +219,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ @@ -227,13 +227,13 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ @@ -242,14 +242,14 @@ "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "note": null, "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -260,13 +260,13 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "notes": null, @@ -280,8 +280,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -319,7 +319,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "validFrom": "2026-02-01T00:00:00.000Z", @@ -333,7 +333,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "validFrom": "2026-03-01T00:00:00.000Z", @@ -347,7 +347,7 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ @@ -430,7 +430,7 @@ "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ @@ -550,12 +550,27 @@ "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "bodySite": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -565,12 +580,12 @@ "note": "rt-IllnessEpisode-noteEncrypted", "bodySite": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -581,12 +596,12 @@ } ], "note": "rt-IllnessDayLog-noteEncrypted", - "updatedAt": "" + "updatedAt": "2026-05-02T19:09:00.000Z" } ] }, { - "id": "", + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -596,14 +611,14 @@ "note": null, "bodySite": "Stomach", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -612,19 +627,19 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": "rt-Allergy-reactionEncrypted", "note": "rt-Allergy-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ @@ -647,7 +662,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -659,7 +674,7 @@ "sourceSystem": "PAPERLESS", "sourceId": "4711", "sourceInstance": "https://paperless.example.test", - "createdAt": "", + "createdAt": "2026-05-04T01:24:00.000Z", "summary": "rt-InboundDocument-summaryEncrypted" } ], @@ -727,7 +742,7 @@ ], "correlationPatterns": [ { - "createdAt": "", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -828,7 +843,7 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { @@ -896,57 +911,57 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-locationEncrypted", "phone": "rt-Practitioner-phoneEncrypted", "note": "ring the upper bell", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z" } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reason": "annual check", "outcome": "ferritin back in range, recheck in a year", "bodySite": "rt-Encounter-bodySiteEncrypted", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "" + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -954,13 +969,13 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -968,25 +983,25 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "note": "sore arm for a day, nothing else", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -1003,43 +1018,43 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summary": "earlier turns: weight trend and evening walks", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "my readings look higher this week, is that real?", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -1052,7 +1067,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "content": "the last seven mornings average 4 mmHg above the fortnight before", "metricSourceJson": null, @@ -1068,17 +1083,17 @@ "attachments": [] }, { - "id": "", + "id": "", "title": "About my June labs", "documentScoped": true, "summary": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "What does the ferritin mean?", "metricSourceJson": null, @@ -1093,7 +1108,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -1101,21 +1116,21 @@ ], "coachFacts": [ { - "id": "", + "id": "", "fact": "does not tolerate ACE inhibitors", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCue": "if the evening reading is over 140", "thenAction": "then walk twenty minutes before dinner tomorrow", @@ -1123,42 +1138,42 @@ "outcome": "rt-CoachPlan-outcomeEncrypted", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" } ], "coachReminders": [ { - "id": "", + "id": "", "note": "ask how the evening walks are going", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" } ], "mentalHealthAssessments": [], "consentReceipts": [], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, @@ -1183,8 +1198,8 @@ "page": 2, "confidence": 0.94 }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "personalRecords": [ @@ -1198,7 +1213,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -1210,7 +1225,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -1222,19 +1237,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -1279,8 +1294,8 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", @@ -1323,8 +1338,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -1334,8 +1349,8 @@ "lat": 41.4, "lon": 2.2, "label": "Barcelona", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -1349,8 +1364,8 @@ "countryCode": "ES", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ @@ -1375,8 +1390,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ @@ -1385,24 +1400,24 @@ "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { "note": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "note": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, @@ -1411,16 +1426,16 @@ "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { "note": "left side, behind the eye", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } @@ -1433,8 +1448,8 @@ "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z" }, { "title": "Parental leave", @@ -1443,43 +1458,43 @@ "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z" } ], "labResultDocumentLinks": [ { "labResultId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -1490,7 +1505,7 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ] }, @@ -1514,7 +1529,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -1577,7 +1592,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -1787,7 +1802,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -1815,12 +1830,12 @@ { "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -1831,7 +1846,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "doseChanges": [ @@ -1840,14 +1855,14 @@ "doseValue": 5, "doseUnit": "mg", "note": "titration note, encrypted at rest", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", "note": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -1857,14 +1872,14 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": "second pack, kept in the kitchen drawer", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "state": "USED_UP", @@ -1878,8 +1893,8 @@ "manufacturer": null, "doseStrength": null, "notes": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "inventoryEvents": [ @@ -1924,7 +1939,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "validFrom": "2026-02-01T00:00:00.000Z", @@ -1938,7 +1953,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "validFrom": "2026-03-01T00:00:00.000Z", @@ -1952,7 +1967,7 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "courses": [ @@ -1960,13 +1975,13 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ] } @@ -1977,7 +1992,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "intakeEvents": [ @@ -2108,7 +2123,7 @@ "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ @@ -2171,12 +2186,27 @@ "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "bodySite": null, + "laterality": null, + "updatedAt": "2026-05-02T17:32:03.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -2184,14 +2214,14 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": "rt-IllnessEpisode-noteEncrypted", - "createdAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", "bodySite": null, "laterality": null, - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -2202,12 +2232,12 @@ } ], "note": "rt-IllnessDayLog-noteEncrypted", - "updatedAt": "" + "updatedAt": "2026-05-02T19:09:00.000Z" } ] }, { - "id": "", + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -2215,10 +2245,10 @@ "resolvedAt": null, "parentConditionId": null, "note": null, - "createdAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", "bodySite": "Stomach", "laterality": "BOTH", - "updatedAt": "", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], @@ -2228,24 +2258,24 @@ "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "note": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "note": null, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, @@ -2254,16 +2284,16 @@ "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "note": "left side, behind the eye", "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } @@ -2276,8 +2306,8 @@ "precision": "MONTH", "title": "Moved to the coast", "note": "the flat with the long hallway", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z" }, { "category": "WORK", @@ -2286,13 +2316,13 @@ "precision": "DAY", "title": "Parental leave", "note": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z" } ], "allergies": [ { - "id": "", + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -2301,19 +2331,19 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": "rt-Allergy-reactionEncrypted", "note": "rt-Allergy-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ @@ -2336,7 +2366,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -2349,20 +2379,20 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "summary": "rt-InboundDocument-summaryEncrypted", - "createdAt": "" + "createdAt": "2026-05-04T01:24:00.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, @@ -2387,8 +2417,8 @@ "page": 2, "confidence": 0.94 }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "nutrientDays": [ @@ -2399,7 +2429,7 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ], "healthProfile": { @@ -2464,7 +2494,7 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "" + "createdAt": "2026-05-03T18:38:00.000Z" } ], "intradayProfiles": [ @@ -2548,7 +2578,7 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { @@ -2616,57 +2646,57 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-locationEncrypted", "phone": "rt-Practitioner-phoneEncrypted", "note": "ring the upper bell", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z" } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reason": "annual check", "outcome": "ferritin back in range, recheck in a year", "bodySite": "rt-Encounter-bodySiteEncrypted", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "" + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", + "encounterId": "", "targetId": "", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -2674,13 +2704,13 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -2688,70 +2718,70 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "note": "sore arm for a day, nothing else", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "labResultDocumentLinks": [ { "labResultId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summary": "earlier turns: weight trend and evening walks", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "my readings look higher this week, is that real?", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -2764,7 +2794,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "content": "the last seven mornings average 4 mmHg above the fortnight before", "metricSourceJson": null, @@ -2780,17 +2810,17 @@ "attachments": [] }, { - "id": "", + "id": "", "title": "About my June labs", "documentScoped": true, "summary": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "What does the ferritin mean?", "metricSourceJson": null, @@ -2805,7 +2835,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -2815,21 +2845,21 @@ "consentReceipts": [], "coachFacts": [ { - "id": "", + "id": "", "fact": "does not tolerate ACE inhibitors", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCue": "if the evening reading is over 140", "thenAction": "then walk twenty minutes before dinner tomorrow", @@ -2837,32 +2867,32 @@ "outcome": "rt-CoachPlan-outcomeEncrypted", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" } ], "coachReminders": [ { - "id": "", + "id": "", "note": "ask how the evening walks are going", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -2879,28 +2909,28 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "personalRecords": [ @@ -2914,7 +2944,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -2926,7 +2956,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -2938,19 +2968,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -2995,8 +3025,8 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", @@ -3039,8 +3069,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -3050,8 +3080,8 @@ "lat": 41.4, "lon": 2.2, "label": "Barcelona", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -3065,8 +3095,8 @@ "countryCode": "ES", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ @@ -3091,8 +3121,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "manifest": { @@ -3131,7 +3161,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -3187,7 +3217,7 @@ "labResults": 1, "nutrientDays": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json index 29326a9cd..77fa33c88 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery-over-live.json @@ -9,47 +9,47 @@ "exportedAt": "2026-08-01T00:00:00.000Z", "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, - "cycles": 2, - "cycleDayLogs": 1, + "moodEntries": 2, + "cycles": 3, + "cycleDayLogs": 2, "nutrientDays": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, "extractedFacts": 1, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, - "customMetricEntries": 2, + "healthProfileFactRevisions": 2, + "customMetrics": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 1, "relationLinks": 5, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "personalRecords": 3, "userAchievements": 2, @@ -65,47 +65,47 @@ "cleared": { "measurements": 5, "medications": 1, - "intakeEvents": 1, - "moodEntries": 1, + "intakeEvents": 2, + "moodEntries": 2, "notificationChannels": 0, "pushSubscriptions": 0, "telegramScheduledDeletions": 0, - "cycles": 2, - "cycleDayLogs": 1, + "cycles": 3, + "cycleDayLogs": 2, "cycleProfile": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, "nutrientDays": 1, - "illnessEpisodes": 2, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 0, "extractedFacts": 0, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, + "healthProfileFactRevisions": 2, + "customMetrics": 2, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 0, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 0, "relationLinks": 0, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, "coachConversationDocuments": 0, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "personalRecords": 3, "userAchievements": 2, @@ -114,7 +114,7 @@ "dawarichDays": 1, "ecgRecordings": 1, "symptomDefinitions": 2, - "lifeEvents": 2 + "lifeEvents": 3 } } }, @@ -281,47 +281,47 @@ "cleared": { "measurements": 5, "medications": 1, - "intakeEvents": 1, - "moodEntries": 1, + "intakeEvents": 2, + "moodEntries": 2, "notificationChannels": 0, "pushSubscriptions": 0, "telegramScheduledDeletions": 0, - "cycles": 2, - "cycleDayLogs": 1, + "cycles": 3, + "cycleDayLogs": 2, "cycleProfile": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, "nutrientDays": 1, - "illnessEpisodes": 2, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 0, "extractedFacts": 0, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, + "healthProfileFactRevisions": 2, + "customMetrics": 2, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 0, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 0, "relationLinks": 0, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, "coachConversationDocuments": 0, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "personalRecords": 3, "userAchievements": 2, @@ -330,7 +330,7 @@ "dawarichDays": 1, "ecgRecordings": 1, "symptomDefinitions": 2, - "lifeEvents": 2 + "lifeEvents": 3 }, "accountSettingsApplied": 62, "skipped": { @@ -340,32 +340,32 @@ "restored": { "measurements": 5, "medications": 1, - "intakeEvents": 1, - "moodEntries": 1, - "cycles": 2, - "cycleDayLogs": 1, - "labResults": 1, + "intakeEvents": 2, + "moodEntries": 2, + "cycles": 3, + "cycleDayLogs": 2, + "labResults": 2, "biomarkers": 1, "nutrientDays": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, - "customMetricEntries": 2, + "healthProfileFactRevisions": 2, + "customMetrics": 2, + "customMetricEntries": 3, "correlationPatterns": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 1, "relationLinks": 5, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2 } } diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index c254dae87..439fe95bc 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -9,47 +9,47 @@ "exportedAt": "2026-08-01T00:00:00.000Z", "measurements": 5, "medications": 1, - "intakeEvents": 1, + "intakeEvents": 2, "medicationSideEffects": 1, "medicationEfficacyTargets": 1, "medicationScheduleRevisions": 3, - "moodEntries": 1, - "cycles": 2, - "cycleDayLogs": 1, + "moodEntries": 2, + "cycles": 3, + "cycleDayLogs": 2, "nutrientDays": 1, - "labResults": 1, + "labResults": 2, "biomarkers": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "documentConditionLinks": 1, "extractedFacts": 1, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, - "customMetricEntries": 2, + "healthProfileFactRevisions": 2, + "customMetrics": 2, + "customMetricEntries": 3, "correlationPatterns": 1, "intradayProfiles": 1, "healthScoreRecords": 1, "onboardingRecords": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 1, "relationLinks": 5, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2, "coachConversations": 2, "coachMessages": 3, - "coachFacts": 1, - "coachPlans": 1, - "coachReminders": 1, - "mentalHealthAssessments": 1, + "coachFacts": 2, + "coachPlans": 2, + "coachReminders": 2, + "mentalHealthAssessments": 2, "consentReceipts": 2, "personalRecords": 3, "userAchievements": 2, @@ -340,32 +340,32 @@ "restored": { "measurements": 5, "medications": 1, - "intakeEvents": 1, - "moodEntries": 1, - "cycles": 2, - "cycleDayLogs": 1, - "labResults": 1, + "intakeEvents": 2, + "moodEntries": 2, + "cycles": 3, + "cycleDayLogs": 2, + "labResults": 2, "biomarkers": 1, "nutrientDays": 1, - "illnessEpisodes": 2, - "illnessDayLogs": 1, - "allergies": 1, - "familyHistory": 1, + "illnessEpisodes": 4, + "illnessDayLogs": 2, + "allergies": 2, + "familyHistory": 2, "workouts": 1, "documents": 1, "healthProfile": 1, - "healthProfileFactRevisions": 1, - "customMetrics": 1, - "customMetricEntries": 2, + "healthProfileFactRevisions": 2, + "customMetrics": 2, + "customMetricEntries": 3, "correlationPatterns": 1, - "practitioners": 1, - "encounters": 1, + "practitioners": 2, + "encounters": 2, "encounterLinks": 3, - "vaccinations": 1, + "vaccinations": 2, "vaccinationLinks": 1, "relationLinks": 5, - "customVaccines": 1, - "measurementReminders": 1, + "customVaccines": 2, + "measurementReminders": 2, "measurementReminderEvents": 2 } } @@ -427,8 +427,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -451,8 +451,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" }, { "id": "", @@ -475,8 +475,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -499,8 +499,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -513,7 +513,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -523,8 +523,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" } ], "IntradayCumulativeProfile": [ @@ -590,8 +590,8 @@ "basis": "HOUR_BUCKETS", "bucketCount": 3, "timezone": "Europe/Berlin", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z" } ], "Medication": [ @@ -628,8 +628,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T11:50:00.000Z", + "updatedAt": "2026-05-01T11:51:00.000Z" } ], "MedicationSchedule": [ @@ -687,21 +687,42 @@ "attributionSource": "USER_PIN", "source": "API", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "medicationId": "", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", + "source": "WEB", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MedicationSideEffect": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "occurredAt": "2026-07-01T21:00:00.000Z", @@ -709,94 +730,94 @@ "entry": "NAUSEA", "severity": 3, "notes": null, - "notesEncrypted": "", - "createdAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z" } ], "MedicationPauseEra": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "MedicationDoseChange": [ { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", "note": null, - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "MedicationCategoryAssignment": [ { "medicationId": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "updatedAt": "" + "updatedAt": "2026-05-01T21:22:00.000Z" } ], "MedicationCategoryLabel": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "MedicationCourse": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", + "updatedAt": "2026-05-02T00:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T00:46:01.000Z", + "updatedAt": "2026-05-02T00:47:01.000Z" } ], "MedicationIngredient": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "nutrient": "vitamin_d", @@ -805,13 +826,13 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T02:29:00.000Z", + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "MoodEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", "mood": "GUT", @@ -823,21 +844,45 @@ "stabilityA5": 3, "tags": "[\"round-trip-tag\"]", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "moodLoggedAt": "2026-07-01T20:00:00.000Z", "tz": "rt-MoodEntry-tz", - "syncedAt": "", - "createdAt": "", - "updatedAt": "", + "syncedAt": "2026-05-02T04:17:00.000Z", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "moodA1": null, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MOODLOG", + "externalId": null, + "moodLoggedAt": "2026-07-03T20:00:00.000Z", + "tz": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MoodContext": [ { - "moodEntryId": "", + "moodEntryId": "", "userId": "round-trip-owner", "workStatus": "regular", "workMinutes": 3, @@ -856,23 +901,23 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } ], "MoodEntryTagLink": [ { - "moodEntryId": "", - "moodTagId": "", - "createdAt": "", + "moodEntryId": "", + "moodTagId": "", + "createdAt": "2026-05-02T07:22:00.000Z", "rating": 4 } ], "MoodTag": [ { - "id": "", - "categoryId": "", + "id": "", + "categoryId": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", "icon": "rt-MoodTag-icon", @@ -883,12 +928,12 @@ "scaleMax": 3, "inverse": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "panel": "rt-LabResult-panel", "analyte": "Ferritin", @@ -903,33 +948,71 @@ "sourceValue": 2.5, "sourceUnit": "rt-LabResult-sourceUnit", "takenAt": "2026-06-30T09:00:00.000Z", - "biomarkerId": "", + "biomarkerId": "", "source": "rt-LabResult-source", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "biomarkerId": null, + "source": "MANUAL", + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "" } ], "Biomarker": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z" } ], "Allergy": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "substance": "Penicillin", "category": "FOOD", @@ -937,29 +1020,40 @@ "severity": "MILD", "status": "INACTIVE", "onsetAt": "2026-06-30T12:34:56.789Z", - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "FamilyHistoryEntry": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", "deletedAt": null } ], "IllnessEpisode": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Cold", "type": "INFECTION", @@ -967,15 +1061,31 @@ "onsetAt": "2026-06-20T00:00:00.000Z", "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", + "userId": "round-trip-owner", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "noteEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "deletedAt": null + }, + { + "id": "", "userId": "round-trip-owner", "label": "Iron deficiency", "type": "CHRONIC", @@ -984,162 +1094,215 @@ "resolvedAt": null, "parentConditionId": null, "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null - } - ], - "IllnessDayLog": [ - { - "id": "", - "userId": "round-trip-owner", - "episodeId": "", - "date": "2026-06-21", - "functionalImpact": 3, - "feverC": 2.5, - "noteEncrypted": "", - "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", - "deletedAt": null - } - ], - "IllnessSymptomLink": [ - { - "dayLogId": "", - "symptomId": "", - "severity": 2, - "createdAt": "" - } - ], - "SymptomDefinition": [ - { - "id": "", - "userId": "round-trip-owner", - "labelEncrypted": "", - "icon": "Zap", - "sortOrder": 0, - "isActive": true, - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "labelEncrypted": "", - "icon": "Brain", - "sortOrder": 1, - "isActive": false, - "createdAt": "", - "updatedAt": "" - } - ], - "SymptomEvent": [ - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T09:00:00.000Z", - "intensity": 4, - "noteEncrypted": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T11:00:00.000Z", - "intensity": 8, - "noteEncrypted": "", - "episodeId": null, - "createdAt": "", - "updatedAt": "" }, { "id": "", "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-07-10T18:30:00.000Z", - "intensity": 2, + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, "noteEncrypted": null, - "episodeId": null, - "createdAt": "", - "updatedAt": "" + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "" } ], - "LifeEvent": [ + "IllnessDayLog": [ { "id": "", "userId": "round-trip-owner", - "category": "HOME", - "startDate": "2023-09-01", - "endDate": null, - "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "episodeId": "", + "date": "2026-06-21", + "functionalImpact": 3, + "feverC": 2.5, + "noteEncrypted": "", + "tz": "rt-IllnessDayLog-tz", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null }, { "id": "", "userId": "round-trip-owner", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "" + } + ], + "IllnessSymptomLink": [ + { + "dayLogId": "", + "symptomId": "", + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" + } + ], + "SymptomDefinition": [ + { + "id": "", + "userId": "round-trip-owner", + "labelEncrypted": "", + "icon": "Zap", + "sortOrder": 0, + "isActive": true, + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "labelEncrypted": "", + "icon": "Brain", + "sortOrder": 1, + "isActive": false, + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z" + } + ], + "SymptomEvent": [ + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-06-21T09:00:00.000Z", + "intensity": 4, + "noteEncrypted": null, + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-06-21T11:00:00.000Z", + "intensity": 8, + "noteEncrypted": "", + "episodeId": null, + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-07-10T18:30:00.000Z", + "intensity": 2, + "noteEncrypted": null, + "episodeId": null, + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" + } + ], + "LifeEvent": [ + { + "id": "", + "userId": "round-trip-owner", + "category": "HOME", + "startDate": "2023-09-01", + "endDate": null, + "precision": "MONTH", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", + "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "titleEncrypted": "", + "noteEncrypted": null, + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "UserHealthProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", "aiIncludedSections": [ "ABOUT_ME" ], "emergencyBloodType": "A_POS", "organDonorStatus": "YES", "advanceDirectiveStatus": "EXISTS", - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", "lifeConfirmedJson": { "roundTrip": "rt-UserHealthProfile-lifeConfirmedJson" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" } ], "HealthProfileFactRevision": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "SMOKING_STATUS", - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "kind": "SMOKING_STATUS", + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "CycleProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, @@ -1151,13 +1314,30 @@ "rawChartMode": true, "discreetNotifications": true, "sensitiveCategoryEncryption": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z" } ], "MenstrualCycle": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", @@ -1170,11 +1350,11 @@ "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-03", "endDate": null, @@ -1186,17 +1366,17 @@ "tz": null, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "" + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z" } ], "CycleDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-02", - "cycleId": "", + "cycleId": "", "flow": "HEAVY", "intermenstrualBleeding": true, "basalBodyTempC": 2.5, @@ -1211,20 +1391,49 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-06-03", + "cycleId": "", + "flow": "LIGHT", + "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": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z" } ], "CycleSymptom": [ { - "id": "", + "id": "", "categoryId": "csc_physical", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", @@ -1232,20 +1441,20 @@ "sortOrder": 3, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "CycleSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 3, - "createdAt": "" + "createdAt": "2026-05-03T13:23:00.000Z" } ], "CustomMetric": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Grip strength", "unit": "kg", @@ -1254,40 +1463,66 @@ "decimals": 3, "description": "rt-CustomMetric-description", "correlationEnabled": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "" } ], "CustomMetricEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null, + "noteEncrypted": null, + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "customMetricId": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null } ], "CorrelationPattern": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", @@ -1305,13 +1540,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T18:38:00.000Z", + "updatedAt": "2026-05-03T18:39:00.000Z" } ], "HealthScoreRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -1329,13 +1564,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", - "createdAt": "" + "computedAt": "2026-05-03T20:12:00.000Z", + "createdAt": "2026-05-03T20:13:00.000Z" } ], "Workout": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", @@ -1355,8 +1590,8 @@ "metadata": { "roundTrip": "rt-Workout-metadata" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z" } ], "NutrientIntakeDay": [ @@ -1368,20 +1603,20 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "InboundDocument": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", "mimeType": "application/pdf", "byteSize": 57, - "contentEncrypted": "", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "status": "EXTRACTING", @@ -1389,7 +1624,7 @@ "reportDate": "2026-06-30T12:34:56.789Z", "documentDate": "2026-06-30T12:34:56.789Z", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -1398,76 +1633,126 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "aiReadDeferred": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", "deletedAt": null } ], "Practitioner": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "Encounter": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null } ], "EncounterDocumentLink": [ { - "id": "", - "encounterId": "", - "documentId": "", + "id": "", + "encounterId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T06:04:00.000Z" } ], "EncounterLabLink": [ { - "id": "", - "encounterId": "", - "labResultId": "", + "id": "", + "encounterId": "", + "labResultId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "EncounterConditionLink": [ { - "id": "", - "encounterId": "", - "episodeId": "", + "id": "", + "encounterId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T09:24:00.000Z" } ], "VaccinationRecord": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", @@ -1476,28 +1761,41 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null } ], "VaccinationDocumentLink": [ { - "id": "", - "vaccinationId": "", - "documentId": "", + "id": "", + "vaccinationId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T12:44:00.000Z" } ], "CustomVaccine": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "deletedAt": "", + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip travel combo", "components": [ @@ -1507,13 +1805,13 @@ "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "MeasurementReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", @@ -1532,54 +1830,78 @@ "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, "lastNotifiedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "lastNotifiedAt": null, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "MeasurementReminderEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" } ], "CoachConversation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, "documentScoped": true }, { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", - "summaryEncrypted": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, "documentScoped": false @@ -1587,24 +1909,24 @@ ], "CoachMessage": [ { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "assistant", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1615,10 +1937,10 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1631,67 +1953,114 @@ ], "CoachConversationDocument": [ { - "conversationId": "", - "documentId": "", + "conversationId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ], "CoachFact": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "factEncrypted": "", + "factEncrypted": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z", + "deletedAt": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null + }, + { + "id": "", + "userId": "round-trip-owner", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null, "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "" + "sourceMessageId": "" } ], "CoachPlan": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "outcomeEncrypted": "", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "outcomeEncrypted": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "status": "active", + "reviewDate": null, + "outcomeEncrypted": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z", + "deletedAt": "" } ], "CoachReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "noteEncrypted": "", + "noteEncrypted": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null } ], "MedicationInventoryItem": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "ACTIVE", @@ -1699,18 +2068,18 @@ "unitsTotal": "decimal:30", "unitsRemaining": "decimal:21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": null, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "USED_UP", @@ -1725,20 +2094,20 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "MedicationInventoryEvent": [ { - "id": "", + "id": "", "medicationId": "", "delta": -8, "reason": "consumed", "occurredAt": "2026-07-09T07:00:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "delta": 30, "reason": "purchased", @@ -1747,26 +2116,26 @@ ], "MoodTagCategory": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [ { "userId": "round-trip-owner", "moodTagId": "mt_grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "ReminderPhaseConfig": [ { - "id": "", + "id": "", "medicationId": "", "greenValue": 45, "greenMode": "PERCENT", @@ -1780,12 +2149,32 @@ ], "MentalHealthAssessment": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -1796,59 +2185,59 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "ConsentReceipt": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "DocumentConditionLink": [ { - "id": "", - "documentId": "", - "episodeId": "", + "id": "", + "documentId": "", + "episodeId": "", "userId": "round-trip-owner", "createdAt": "2026-07-02T09:00:00.000Z" } ], "ExtractedFact": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "userId": "round-trip-owner", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "dataEncrypted": "", - "provenanceEncrypted": "", - "committedRecordId": "", + "dataEncrypted": "", + "provenanceEncrypted": "", + "committedRecordId": "", "committedRecordType": "labResult", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "PersonalRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_10km_time", @@ -1859,10 +2248,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_5km_time", @@ -1873,10 +2262,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WEIGHT", "metricSlot": null, @@ -1887,31 +2276,31 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "UserAchievement": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" } ], "EnvironmentContext": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -1948,14 +2337,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -1992,24 +2381,24 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "EnvironmentTravelLocation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-10", "endDate": "2026-06-20", - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "DawarichDay": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "totalDistanceM": 48300, @@ -2017,16 +2406,16 @@ "trainDistanceM": 30500, "placesVisited": 4, "awayFromHomeMinutes": 1440, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "MedicationScheduleRevision": [ { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -2039,10 +2428,10 @@ ], "source": "ARCHIVED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-02-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -2054,11 +2443,11 @@ } ], "source": "ARCHIVED", - "supersededByRevisionId": "", - "createdAt": "" + "supersededByRevisionId": "", + "createdAt": "2026-05-06T04:47:02.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-03-01T00:00:00.000Z", "validUntil": "2026-05-01T00:00:00.000Z", @@ -2071,28 +2460,28 @@ ], "source": "MANUAL", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "MedicationEfficacyTarget": [ { - "id": "", + "id": "", "medicationId": "", "measurementType": "WEIGHT", - "biomarkerId": "", + "biomarkerId": "", "primary": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z" } ], "EcgRecording": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -2100,13 +2489,13 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "OnboardingRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "needsJson": { "areas": [ @@ -2169,80 +2558,80 @@ }, "modulesDerivedAt": "2026-07-01T08:00:00.000Z", "completedAt": "2026-07-01T08:00:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z" } ], "BiomarkerAlias": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "biomarkerId": "", + "biomarkerId": "", "name": "Ferr.", "nameFolded": "ferr.", "source": "IMPORT_CHOICE", - "createdAt": "" + "createdAt": "2026-05-06T11:26:00.000Z" } ], "BiomarkerMerge": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "targetBiomarkerId": "", + "targetBiomarkerId": "", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "LabResultDocumentLink": [ { - "id": "", - "labResultId": "", - "documentId": "", + "id": "", + "labResultId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T14:44:00.000Z" } ], "EncounterMedicationLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "medicationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "EncounterVaccinationLink": [ { - "id": "", - "encounterId": "", - "vaccinationId": "", + "id": "", + "encounterId": "", + "vaccinationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T18:04:00.000Z" } ], "MedicationDocumentLink": [ { - "id": "", + "id": "", "medicationId": "", - "documentId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T19:44:00.000Z" } ], "MedicationConditionLink": [ { - "id": "", + "id": "", "medicationId": "", - "episodeId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T21:24:00.000Z" } ], "AccountGrant": [ { - "id": "", + "id": "", "grantorId": "round-trip-owner", "granteeId": "parity-delegate", "access": "READ", @@ -2273,7 +2662,7 @@ "timezone": "America/Chicago", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "environmentAqHistoryJson": null, "codexAccessTokenEncrypted": null, @@ -2438,7 +2827,7 @@ "passkeyUpgradeNudgeDismissed": true, "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber" }, "AppSettings": { @@ -2450,7 +2839,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2464,12 +2853,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2518,7 +2907,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2532,12 +2921,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2581,7 +2970,7 @@ "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", "insuranceNumber": null, - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber", "lastReportPracticeName": "rt-User-lastReportPracticeName", "avatarBytes": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", @@ -2589,7 +2978,7 @@ "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "timezone": "America/Chicago", "locale": "fr", @@ -2702,8 +3091,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -2725,8 +3114,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -2748,8 +3137,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -2761,7 +3150,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -2771,8 +3160,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" }, { "id": "", @@ -2794,17 +3183,19 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" } ], "customMedicationCategories": [ { "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "label": "Heart and circulation", + "label": null, + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "medications": [ @@ -2834,7 +3225,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T11:51:00.000Z", + "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", "active": false, @@ -2842,7 +3234,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "id": "", @@ -2887,8 +3279,8 @@ { "id": "", "notes": null, - "notesEncrypted": "", - "createdAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z", "occurredAt": "2026-07-01T21:00:00.000Z", "category": "GI", "entry": "NAUSEA", @@ -2900,50 +3292,55 @@ "id": "", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ { "id": "", + "updatedAt": "2026-05-02T02:30:00.000Z", "nutrient": "vitamin_d", "amountPerUnit": 25, "unit": "ug", "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", - "note": "the March flu", - "createdAt": "" + "note": null, + "noteEncrypted": "", + "updatedAt": "2026-05-02T00:47:00.000Z", + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "noteEncrypted": null, + "updatedAt": "2026-05-02T00:47:01.000Z", + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ { "id": "", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -2952,26 +3349,26 @@ "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ { "id": "", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "state": "ACTIVE", "containerType": "BLISTER", "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "id": "", @@ -2987,8 +3384,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -3030,7 +3427,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "id": "", @@ -3045,7 +3442,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "id": "", @@ -3060,14 +3457,14 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ { "id": "", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z", "measurementType": "WEIGHT", "biomarkerName": "Ferritin", "primary": false @@ -3079,17 +3476,38 @@ { "id": "", "medicationId": "", + "autoMissed": false, + "attributionSource": "AUTO", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "", + "medication": "Round-trip tablet", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "source": "WEB", + "autoRecorded": false + }, + { + "id": "", + "medicationId": "", "autoMissed": true, "attributionSource": "USER_PIN", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null, "medication": "Round-trip tablet", @@ -3103,12 +3521,45 @@ "moodEntries": [ { "note": null, - "noteEncrypted": "", - "syncedAt": "", + "noteEncrypted": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "syncVersion": 0, + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "linkCreatedAt": [], + "id": "", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "a1": null, + "a2": null, + "a3": null, + "a4": null, + "a5": null, + "tags": null, + "source": "MOODLOG", + "loggedAt": "2026-07-03T20:00:00.000Z", + "externalId": null, + "tz": null, + "deletedAt": "", + "factors": [], + "structuredTags": [], + "context": null + }, + { + "note": null, + "noteEncrypted": "", + "syncedAt": "2026-05-02T04:17:00.000Z", "syncVersion": 3, - "createdAt": "", - "updatedAt": "", - "id": "", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", + "linkCreatedAt": [ + { + "key": "round_trip_factor", + "createdAt": "2026-05-02T07:22:00.000Z" + } + ], + "id": "", "date": "2026-07-01", "mood": "GUT", "score": 4, @@ -3131,7 +3582,9 @@ ], "structuredTags": [], "context": { - "notesEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z", "workStatus": "regular", "workMinutes": 3, "overtimeMinutes": 3, @@ -3154,41 +3607,41 @@ ], "customMoodTagCategories": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ { - "id": "", + "id": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", - "categoryId": "", + "categoryId": "", "kind": "RATED", "isActive": false, "icon": "rt-MoodTag-icon", "sortOrder": 3, - "labelEncrypted": "", + "labelEncrypted": "", "scaleMin": 3, "scaleMax": 3, "inverse": true } ], "cycleProfile": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, "typicalCycleLength": 3, @@ -3202,13 +3655,29 @@ }, "cycles": [ { - "id": "", + "id": "", + "isPredicted": false, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "tz": null + }, + { + "id": "", "isPredicted": true, "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", "periodEndDate": "rt-MenstrualCycle-periodEndDate", @@ -3218,13 +3687,13 @@ "tz": "rt-MenstrualCycle-tz" }, { - "id": "", + "id": "", "isPredicted": false, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "", + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z", "startDate": "2026-06-03", "endDate": null, "periodEndDate": null, @@ -3236,12 +3705,12 @@ ], "cycleDayLogs": [ { - "id": "", - "cycleId": "", + "id": "", + "cycleId": "", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z", "date": "2026-06-02", "flow": "HEAVY", "intermenstrualBleeding": true, @@ -3257,8 +3726,8 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", @@ -3270,28 +3739,65 @@ "key": "custom:round-trip-cramps", "severity": 3 } + ], + "symptomLinkCreatedAt": [ + { + "key": "custom:round-trip-cramps", + "createdAt": "2026-05-03T13:23:00.000Z" + } ] + }, + { + "id": "", + "cycleId": "", + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z", + "date": "2026-06-03", + "flow": "LIGHT", + "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, + "symptomKeys": [], + "symptomSeverities": [], + "symptomLinkCreatedAt": [] } ], "customSymptoms": [ { - "id": "", + "id": "", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", "categoryId": "csc_physical", "icon": "rt-CycleSymptom-icon", "sortOrder": 3, "isActive": false, - "labelEncrypted": "" + "labelEncrypted": "" } ], "labResults": [ { - "id": "", - "biomarkerId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "biomarkerId": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null, "note": null, "panel": "rt-LabResult-panel", @@ -3309,45 +3815,88 @@ "takenAt": "2026-06-30T09:00:00.000Z", "source": "rt-LabResult-source", "biomarkerName": "Ferritin" + }, + { + "id": "", + "biomarkerId": null, + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "", + "note": null, + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "source": "MANUAL", + "biomarkerName": null } ], "biomarkers": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, "panel": "rt-Biomarker-panel", "hidden": true, - "context": "fasted, morning draw", + "context": null, + "contextEncrypted": "", "analyteKey": "rt-Biomarker-analyteKey", "aliases": [ { "name": "Ferr.", "source": "IMPORT_CHOICE", - "id": "", - "createdAt": "" + "id": "", + "createdAt": "2026-05-06T11:26:00.000Z" } ] } ], "biomarkerMerges": [ { - "id": "", - "targetBiomarkerId": "", + "id": "", + "targetBiomarkerId": "", "targetName": "Ferritin", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { - "id": "", + "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "deletedAt": null, + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -3355,37 +3904,70 @@ "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null, "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null, "symptoms": [ { "key": "round_trip_cough", - "severity": 2 + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" } ] + }, + { + "id": "", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "note": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "", + "symptoms": [] } ] }, { - "id": "", + "id": "", + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, + "note": null, + "noteEncrypted": null, + "bodySite": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "", + "dayLogs": [] + }, + { + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -3395,17 +3977,33 @@ "note": null, "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null, "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reaction": null, + "note": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -3414,29 +4012,42 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": null, "note": null, - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "familyHistory": [ { - "id": "", + "id": "", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "note": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", + "deletedAt": null } ], "workouts": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z", "metadata": { "roundTrip": "rt-Workout-metadata" }, @@ -3459,7 +4070,7 @@ ], "documents": [ { - "id": "", + "id": "", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", @@ -3471,14 +4082,14 @@ "sourceSystem": "PAPERLESS", "sourceId": "4711", "sourceInstance": "https://paperless.example.test", - "createdAt": "", - "updatedAt": "", - "contentEncrypted": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "providerType": "rt-InboundDocument-providerType", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -3505,7 +4116,7 @@ } }, "healthProfile": { - "id": "", + "id": "", "aboutMe": null, "conditions": null, "allergies": null, @@ -3522,35 +4133,46 @@ "emergencyContacts": null, "emergencyImplants": null, "emergencyNote": null, - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", - "createdAt": "", - "updatedAt": "" + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" }, "healthProfileFacts": [ { - "id": "", + "id": "", "kind": "SMOKING_STATUS", "value": null, - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "kind": "SMOKING_STATUS", + "value": null, + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "customMetrics": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null, "name": "Grip strength", "unit": "kg", @@ -3561,33 +4183,57 @@ "correlationEnabled": true, "entries": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "", + "noteEncrypted": null, + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null + }, + { + "id": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null, - "noteEncrypted": "", + "noteEncrypted": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": null }, { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null, - "noteEncrypted": "", + "noteEncrypted": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null } ] + }, + { + "id": "", + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "entries": [] } ], "correlationPatterns": [ { - "id": "", - "updatedAt": "", - "createdAt": "", + "id": "", + "updatedAt": "2026-05-03T18:39:00.000Z", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -3609,9 +4255,9 @@ "decryptFailures": [], "intradayProfiles": [ { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z", "type": "ACTIVITY_STEPS", "dateKey": "2026-07-01", "hourlyCumulative": [ @@ -3675,8 +4321,8 @@ ], "healthScoreRecords": [ { - "id": "", - "createdAt": "", + "id": "", + "createdAt": "2026-05-03T20:13:00.000Z", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", "composite": 71, @@ -3693,13 +4339,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { - "id": "", - "createdAt": "", - "updatedAt": "", + "id": "", + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z", "needs": { "recordTarget": "me", "areas": [ @@ -3764,61 +4410,102 @@ }, "practitioners": [ { - "id": "", + "id": "", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" } ], "encounterDocumentLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T06:04:00.000Z" } ], "encounterLabLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T07:44:00.000Z" } ], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z", + "deletedAt": "" + }, + { + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -3826,14 +4513,14 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z", "deletedAt": null } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -3841,26 +4528,44 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null + }, + { + "id": "", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" } ], "vaccinationDocumentLinks": [ { - "vaccinationId": "", - "targetId": "", - "createdAt": "" + "vaccinationId": "", + "targetId": "", + "createdAt": "2026-05-04T12:44:00.000Z" } ], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -3877,46 +4582,68 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": false, - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -3927,35 +4654,35 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" } ], "attachments": [] }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", "documentScoped": true, "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", - "contentEncrypted": "", + "contentEncrypted": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -3968,7 +4695,7 @@ ], "attachments": [ { - "documentId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ] @@ -3976,61 +4703,124 @@ ], "coachFacts": [ { - "id": "", - "factEncrypted": "", + "id": "", + "factEncrypted": "", "deletedAt": null, "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" + }, + { + "id": "", + "factEncrypted": "", + "deletedAt": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null, + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", - "outcomeEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", + "outcomeEncrypted": "", "deletedAt": null, "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" + }, + { + "id": "", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "outcomeEncrypted": null, + "deletedAt": "", + "status": "active", + "reviewDate": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z" } ], "coachReminders": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "deletedAt": null, "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" + }, + { + "id": "", + "noteEncrypted": "", + "deletedAt": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z" } ], "mentalHealthAssessments": [ { - "id": "", + "id": "", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -4041,49 +4831,49 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "consentReceipts": [ { - "id": "", + "id": "", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "documentConditionLinks": [ { - "documentId": "", - "episodeId": "", + "documentId": "", + "episodeId": "", "createdAt": "2026-07-02T09:00:00.000Z" } ], "extractedFacts": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "committedRecordId": "", + "committedRecordId": "", "committedRecordType": "labResult", - "dataEncrypted": "", - "provenanceEncrypted": "", - "createdAt": "", - "updatedAt": "" + "dataEncrypted": "", + "provenanceEncrypted": "", + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "personalRecords": [ @@ -4097,7 +4887,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -4109,7 +4899,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -4121,19 +4911,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -4141,7 +4931,7 @@ "date": "2026-06-15", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "TRAVEL", "tempMin": 19.4, @@ -4179,14 +4969,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", "lat": null, "lon": null, - "locationEncrypted": "", + "locationEncrypted": "", "locationLabel": null, "source": "HOME", "tempMin": 13.1, @@ -4224,8 +5014,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -4235,9 +5025,9 @@ "lat": null, "lon": null, "label": null, - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -4249,17 +5039,17 @@ "placesVisited": 4, "awayFromHomeMinutes": 1440, "countryCode": null, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ { - "id": "", - "waveformEncrypted": "", + "id": "", + "waveformEncrypted": "", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", @@ -4270,120 +5060,132 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { - "id": "", + "id": "", "noteEncrypted": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, { - "id": "", - "labelEncrypted": "", + "id": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { - "id": "", - "noteEncrypted": "", + "id": "", + "noteEncrypted": "", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } ], "lifeEvents": [ { - "id": "", - "titleEncrypted": "", - "noteEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": "", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", - "titleEncrypted": "", + "id": "", + "titleEncrypted": "", + "noteEncrypted": null, + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "titleEncrypted": "", "noteEncrypted": null, "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "labResultDocumentLinks": [ { - "labResultId": "", - "documentId": "", - "createdAt": "" + "labResultId": "", + "documentId": "", + "createdAt": "2026-05-06T14:44:00.000Z" } ], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [ { "medicationId": "", - "documentId": "", - "createdAt": "" + "documentId": "", + "createdAt": "2026-05-06T19:44:00.000Z" } ], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -4394,7 +5196,8 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index f3554e295..5b81b82e2 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -19,7 +19,7 @@ "nutrientDays": 1, "labResults": 1, "biomarkers": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -439,7 +439,7 @@ "labResults": 1, "biomarkers": 1, "nutrientDays": 1, - "illnessEpisodes": 2, + "illnessEpisodes": 3, "illnessDayLogs": 1, "allergies": 1, "familyHistory": 1, @@ -581,7 +581,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": null, "aggregationProvenance": null, @@ -694,7 +694,7 @@ "reorderLeadDays": null, "externalSource": null, "externalId": null, - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "updatedAt": "" } ], @@ -769,7 +769,7 @@ "entry": "NAUSEA", "severity": 3, "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "" } ], @@ -780,7 +780,7 @@ "userId": "round-trip-owner", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "id": "", @@ -788,7 +788,7 @@ "userId": "round-trip-owner", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "MedicationDoseChange": [ @@ -799,8 +799,8 @@ "doseValue": 5, "doseUnit": "mg", "note": null, - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { "id": "", @@ -810,7 +810,7 @@ "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "MedicationCategoryAssignment": [ @@ -825,10 +825,10 @@ "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "", + "createdAt": "2026-05-01T23:06:00.000Z", "updatedAt": "" } ], @@ -839,8 +839,8 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", "updatedAt": "" }, { @@ -850,7 +850,7 @@ "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, - "createdAt": "", + "createdAt": "2026-05-02T00:46:01.000Z", "updatedAt": "" } ], @@ -865,7 +865,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "", + "createdAt": "2026-05-02T02:29:00.000Z", "updatedAt": "" } ], @@ -883,7 +883,7 @@ "stabilityA5": 3, "tags": "[\"round-trip-tag\"]", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "moodLoggedAt": "2026-07-01T20:00:00.000Z", @@ -916,7 +916,7 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "" } @@ -943,7 +943,7 @@ "scaleMax": 3, "inverse": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [ @@ -965,7 +965,7 @@ "takenAt": "2026-06-30T09:00:00.000Z", "biomarkerId": "", "source": "rt-LabResult-source", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -979,7 +979,7 @@ "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", @@ -997,10 +997,10 @@ "severity": "MILD", "status": "INACTIVE", "onsetAt": "2026-06-30T12:34:56.789Z", - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], @@ -1011,9 +1011,9 @@ "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", "deletedAt": null } ], @@ -1027,16 +1027,32 @@ "onsetAt": "2026-06-20T00:00:00.000Z", "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null }, { "id": "", "userId": "round-trip-owner", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "noteEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -1044,129 +1060,129 @@ "resolvedAt": null, "parentConditionId": null, "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null } ], "IllnessDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, - "noteEncrypted": "", + "noteEncrypted": "", "tz": null, "createdAt": "", - "updatedAt": "", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null } ], "IllnessSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 2, "createdAt": "" } ], "SymptomDefinition": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "labelEncrypted": "", + "labelEncrypted": "", "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "labelEncrypted": "", + "labelEncrypted": "", "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z" } ], "SymptomEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, "noteEncrypted": null, "episodeId": "", - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T11:00:00.000Z", - "intensity": 8, - "noteEncrypted": "", - "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "id": "", "userId": "round-trip-owner", - "definitionId": "", + "definitionId": "", + "occurredAt": "2026-06-21T11:00:00.000Z", + "intensity": 8, + "noteEncrypted": "", + "episodeId": null, + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "noteEncrypted": null, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ], "LifeEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "HOME", "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "UserHealthProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", "pendingQuestionsEncrypted": null, "aiIncludedSections": [ "ABOUT_ME" @@ -1174,9 +1190,9 @@ "emergencyBloodType": "A_POS", "organDonorStatus": "YES", "advanceDirectiveStatus": "EXISTS", - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", "lifeConfirmedJson": { "roundTrip": "rt-UserHealthProfile-lifeConfirmedJson" }, @@ -1187,7 +1203,7 @@ "HealthProfileFactRevision": [], "CycleProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, @@ -1206,7 +1222,7 @@ "MenstrualCycle": [], "CycleDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-02", "cycleId": null, @@ -1225,7 +1241,7 @@ "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", "sensitiveEncrypted": null, - "notesEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", @@ -1237,7 +1253,7 @@ ], "CycleSymptom": [ { - "id": "", + "id": "", "categoryId": "csc_physical", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", @@ -1245,20 +1261,20 @@ "sortOrder": 3, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "CycleSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 3, "createdAt": "" } ], "CustomMetric": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Grip strength", "unit": "kg", @@ -1274,33 +1290,33 @@ ], "CustomMetricEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "deletedAt": null } ], "CorrelationPattern": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", @@ -1318,13 +1334,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", + "createdAt": "2026-05-03T18:38:00.000Z", "updatedAt": "" } ], "HealthScoreRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -1342,13 +1358,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", + "computedAt": "2026-05-03T20:12:00.000Z", "createdAt": "" } ], "Workout": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", @@ -1379,43 +1395,43 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "", + "createdAt": "2026-05-03T23:27:00.000Z", "updatedAt": "" } ], "InboundDocument": [], "Practitioner": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": null, "phone": null, - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "Encounter": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null } ], @@ -1423,16 +1439,16 @@ "EncounterLabLink": [], "EncounterConditionLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T09:24:00.000Z" } ], "VaccinationRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", @@ -1441,20 +1457,20 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null } ], "VaccinationDocumentLink": [], "CustomVaccine": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Round-trip travel combo", "components": [ @@ -1464,13 +1480,13 @@ "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "MeasurementReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", @@ -1489,54 +1505,54 @@ "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, "lastNotifiedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null } ], "MeasurementReminderEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" } ], "CoachConversation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, "documentScoped": true }, { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", - "summaryEncrypted": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, "documentScoped": false @@ -1544,24 +1560,24 @@ ], "CoachMessage": [ { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "assistant", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1572,10 +1588,10 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1589,60 +1605,60 @@ "CoachConversationDocument": [], "CoachFact": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "factEncrypted": "", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null, "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "" + "sourceMessageId": "" } ], "CoachPlan": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "outcomeEncrypted": "", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "outcomeEncrypted": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null } ], "CoachReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "noteEncrypted": "", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null } ], "MedicationInventoryItem": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "ACTIVE", @@ -1650,18 +1666,18 @@ "unitsTotal": "decimal:30", "unitsRemaining": "decimal:21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": null, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "USED_UP", @@ -1676,20 +1692,20 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "MedicationInventoryEvent": [ { - "id": "", + "id": "", "medicationId": "", "delta": -8, "reason": "consumed", "occurredAt": "2026-07-09T07:00:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "delta": 30, "reason": "purchased", @@ -1705,19 +1721,19 @@ "sortOrder": 90, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [ { "userId": "round-trip-owner", "moodTagId": "mt_grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "ReminderPhaseConfig": [ { - "id": "", + "id": "", "medicationId": "", "greenValue": 45, "greenMode": "PERCENT", @@ -1735,7 +1751,7 @@ "ExtractedFact": [], "PersonalRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_10km_time", @@ -1746,10 +1762,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_5km_time", @@ -1760,10 +1776,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WEIGHT", "metricSlot": null, @@ -1774,31 +1790,31 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "UserAchievement": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" } ], "EnvironmentContext": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -1835,14 +1851,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -1879,24 +1895,24 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "EnvironmentTravelLocation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-10", "endDate": "2026-06-20", - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "DawarichDay": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "totalDistanceM": 48300, @@ -1904,16 +1920,16 @@ "trainDistanceM": 30500, "placesVisited": 4, "awayFromHomeMinutes": 1440, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "MedicationScheduleRevision": [ { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1926,10 +1942,10 @@ ], "source": "ARCHIVED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-02-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1941,11 +1957,11 @@ } ], "source": "ARCHIVED", - "supersededByRevisionId": "", - "createdAt": "" + "supersededByRevisionId": "", + "createdAt": "2026-05-06T04:47:02.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-03-01T00:00:00.000Z", "validUntil": "2026-05-01T00:00:00.000Z", @@ -1958,12 +1974,12 @@ ], "source": "MANUAL", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "MedicationEfficacyTarget": [ { - "id": "", + "id": "", "medicationId": "", "measurementType": "WEIGHT", "biomarkerId": "", @@ -1974,12 +1990,12 @@ ], "EcgRecording": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -1987,13 +2003,13 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "OnboardingRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "needsJson": { "areas": [ @@ -2062,7 +2078,7 @@ ], "BiomarkerAlias": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "biomarkerId": "", "name": "Ferr.", @@ -2073,47 +2089,47 @@ ], "BiomarkerMerge": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "targetBiomarkerId": "", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "LabResultDocumentLink": [], "EncounterMedicationLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "medicationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "EncounterVaccinationLink": [ { - "id": "", - "encounterId": "", - "vaccinationId": "", + "id": "", + "encounterId": "", + "vaccinationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T18:04:00.000Z" } ], "MedicationDocumentLink": [], "MedicationConditionLink": [ { - "id": "", + "id": "", "medicationId": "", "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T21:24:00.000Z" } ], "AccountGrant": [ { - "id": "", + "id": "", "grantorId": "round-trip-owner", "granteeId": "parity-delegate", "access": "READ", @@ -2144,7 +2160,7 @@ "timezone": "America/Chicago", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "environmentAqHistoryJson": null, "codexAccessTokenEncrypted": null, @@ -2309,7 +2325,7 @@ "passkeyUpgradeNudgeDismissed": true, "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber" }, "AppSettings": { @@ -2321,7 +2337,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2335,12 +2351,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", @@ -2539,7 +2555,7 @@ "label": "Heart and circulation", "sortOrder": 2, "isActive": false, - "createdAt": "" + "createdAt": "2026-05-01T23:06:00.000Z" } ], "medications": [ @@ -2552,7 +2568,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "", + "createdAt": "2026-05-01T11:50:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -2580,12 +2596,12 @@ { "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "ingredients": [ @@ -2596,7 +2612,7 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "" + "createdAt": "2026-05-02T02:29:00.000Z" } ], "courses": [ @@ -2604,13 +2620,13 @@ "startsOn": "2026-03-01", "endsOn": "2026-03-07", "note": "the March flu", - "createdAt": "" + "createdAt": "2026-05-02T00:46:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", "note": null, - "createdAt": "" + "createdAt": "2026-05-02T00:46:01.000Z" } ], "doseChanges": [ @@ -2619,14 +2635,14 @@ "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:00.000Z" }, { "note": null, "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "inventoryItems": [ @@ -2637,13 +2653,13 @@ "unitsTotal": "30", "unitsRemaining": "21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { "notes": null, @@ -2657,8 +2673,8 @@ "purchasedAt": null, "manufacturer": null, "doseStrength": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "phaseConfig": { @@ -2696,7 +2712,7 @@ ], "source": "ARCHIVED", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { "validFrom": "2026-02-01T00:00:00.000Z", @@ -2710,7 +2726,7 @@ ], "source": "ARCHIVED", "supersededByIndex": 2, - "createdAt": "" + "createdAt": "2026-05-06T04:47:02.000Z" }, { "validFrom": "2026-03-01T00:00:00.000Z", @@ -2724,7 +2740,7 @@ ], "source": "MANUAL", "supersededByIndex": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "efficacyTargets": [ @@ -2807,7 +2823,7 @@ "hiddenMoodTags": [ { "key": "grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "customMoodTags": [ @@ -2927,12 +2943,27 @@ "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "illnessEpisodes": [ { "id": "", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "note": null, + "bodySite": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "dayLogs": [] + }, + { + "id": "", "label": "Cold", "type": "INFECTION", "lifecycle": "CHRONIC_ONGOING", @@ -2942,12 +2973,12 @@ "note": "rt-IllnessEpisode-noteEncrypted", "bodySite": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "dayLogs": [ { - "id": "", - "episodeId": "", + "id": "", + "episodeId": "", "date": "2026-06-21", "functionalImpact": 3, "feverC": 2.5, @@ -2958,12 +2989,12 @@ } ], "note": "rt-IllnessDayLog-noteEncrypted", - "updatedAt": "" + "updatedAt": "2026-05-02T19:09:00.000Z" } ] }, { - "id": "", + "id": "", "label": "Iron deficiency", "type": "CHRONIC", "lifecycle": "ACUTE", @@ -2973,14 +3004,14 @@ "note": null, "bodySite": "Stomach", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "dayLogs": [] } ], "allergies": [ { - "id": "", + "id": "", "substance": "Penicillin", "category": "FOOD", "type": "INTOLERANCE", @@ -2989,19 +3020,19 @@ "onsetAt": "2026-06-30T12:34:56.789Z", "reaction": "rt-Allergy-reactionEncrypted", "note": "rt-Allergy-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z" } ], "familyHistory": [ { - "id": "", + "id": "", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, "note": "rt-FamilyHistoryEntry-notesEncrypted", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z" } ], "workouts": [ @@ -3087,7 +3118,7 @@ ], "correlationPatterns": [ { - "createdAt": "", + "createdAt": "2026-05-03T18:38:00.000Z", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", "factorKey": "sleep_duration", @@ -3188,7 +3219,7 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "" + "computedAt": "2026-05-03T20:12:00.000Z" } ], "onboardingRecord": { @@ -3256,45 +3287,45 @@ }, "practitioners": [ { - "id": "", + "id": "", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-locationEncrypted", "phone": "rt-Practitioner-phoneEncrypted", "note": "ring the upper bell", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z" } ], "encounters": [ { - "id": "", + "id": "", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", + "practitionerId": "", "reason": "annual check", "outcome": "ferritin back in range, recheck in a year", "bodySite": "rt-Encounter-bodySiteEncrypted", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "" + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z" } ], "encounterDocumentLinks": [], "encounterLabLinks": [], "encounterConditionLinks": [ { - "encounterId": "", - "targetId": "", - "createdAt": "" + "encounterId": "", + "targetId": "", + "createdAt": "2026-05-04T09:24:00.000Z" } ], "customVaccines": [ { - "id": "", + "id": "", "name": "Round-trip travel combo", "components": [ "typhoid", @@ -3302,13 +3333,13 @@ ], "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "vaccinations": [ { - "id": "", + "id": "", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", "vaccineName": "Tetanus, diphtheria and pertussis", @@ -3316,19 +3347,19 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", "note": "sore arm for a day, nothing else", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z" } ], "vaccinationDocumentLinks": [], "measurementReminders": [ { - "id": "", + "id": "", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", "intervalDays": 7, @@ -3345,43 +3376,43 @@ "snoozedUntil": "2026-07-08T06:00:00.000Z", "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z" } ], "measurementReminderEvents": [ { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" }, { - "id": "", - "reminderId": "", + "id": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" } ], "coachConversations": [ { - "id": "", + "id": "", "title": "How is my blood pressure trending?", "documentScoped": false, "summary": "earlier turns: weight trend and evening walks", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "my readings look higher this week, is that real?", "metricSourceJson": "rt-CoachMessage-metricSourceJson", @@ -3394,7 +3425,7 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", + "id": "", "role": "assistant", "content": "the last seven mornings average 4 mmHg above the fortnight before", "metricSourceJson": null, @@ -3410,17 +3441,17 @@ "attachments": [] }, { - "id": "", + "id": "", "title": "About my June labs", "documentScoped": true, "summary": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "messages": [ { - "id": "", + "id": "", "role": "user", "content": "What does the ferritin mean?", "metricSourceJson": null, @@ -3438,21 +3469,21 @@ ], "coachFacts": [ { - "id": "", + "id": "", "fact": "does not tolerate ACE inhibitors", "category": "constraint", "confidence": 80, - "sourceConversationId": "", + "sourceConversationId": "", "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "", - "createdAt": "", - "updatedAt": "" + "sourceMessageId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z" } ], "coachPlans": [ { - "id": "", + "id": "", "metric": "BLOOD_PRESSURE", "ifCue": "if the evening reading is over 140", "thenAction": "then walk twenty minutes before dinner tomorrow", @@ -3460,27 +3491,27 @@ "outcome": "rt-CoachPlan-outcomeEncrypted", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "" + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z" } ], "coachReminders": [ { - "id": "", + "id": "", "note": "ask how the evening walks are going", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z" } ], "mentalHealthAssessments": [], @@ -3498,7 +3529,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { "metricType": "WALKING_RUNNING_DISTANCE", @@ -3510,7 +3541,7 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { "metricType": "WEIGHT", @@ -3522,19 +3553,19 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "userAchievements": [ { "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" }, { "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" } ], "environmentContexts": [ @@ -3579,8 +3610,8 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { "date": "2026-07-01", @@ -3623,8 +3654,8 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "environmentTravelLocations": [ @@ -3634,8 +3665,8 @@ "lat": 41.4, "lon": 2.2, "label": "Barcelona", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "dawarichDays": [ @@ -3649,8 +3680,8 @@ "countryCode": "ES", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "ecgRecordings": [ @@ -3675,8 +3706,8 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "symptomDefinitions": [ @@ -3685,24 +3716,24 @@ "icon": "Zap", "sortOrder": 0, "isActive": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z", "events": [ { "note": null, "occurredAt": "2026-06-21T09:00:00.000Z", "intensity": 4, - "episodeId": "", - "createdAt": "", - "updatedAt": "" + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" }, { "note": null, "occurredAt": "2026-07-10T18:30:00.000Z", "intensity": 2, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" } ] }, @@ -3711,16 +3742,16 @@ "icon": "Brain", "sortOrder": 1, "isActive": false, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z", "events": [ { "note": "left side, behind the eye", "occurredAt": "2026-06-21T11:00:00.000Z", "intensity": 8, "episodeId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" } ] } @@ -3733,8 +3764,8 @@ "startDate": "2023-09-01", "endDate": null, "precision": "MONTH", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z" }, { "title": "Parental leave", @@ -3743,31 +3774,31 @@ "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z" } ], "labResultDocumentLinks": [], "encounterMedicationLinks": [ { - "encounterId": "", + "encounterId": "", "medicationId": "", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "encounterVaccinationLinks": [ { - "encounterId": "", - "vaccinationId": "", - "createdAt": "" + "encounterId": "", + "vaccinationId": "", + "createdAt": "2026-05-06T18:04:00.000Z" } ], "medicationDocumentLinks": [], "medicationConditionLinks": [ { "medicationId": "", - "episodeId": "", - "createdAt": "" + "episodeId": "", + "createdAt": "2026-05-06T21:24:00.000Z" } ], "nutrientDays": [ @@ -3778,7 +3809,7 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "" + "createdAt": "2026-05-03T23:27:00.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 339fe8c13..0861a6e8b 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -641,7 +641,7 @@ "scaleMax": 5, "inverse": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [], @@ -726,7 +726,7 @@ "sortOrder": 0, "isActive": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [], diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index cdc01f836..7128d4c26 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -487,7 +487,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": null, "aggregationProvenance": null, @@ -668,7 +668,7 @@ "stabilityA5": null, "tags": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "source": "MOODLOG", "externalId": null, "moodLoggedAt": "2026-07-02T20:00:00.000Z", @@ -726,7 +726,7 @@ "takenAt": "2026-07-05T08:00:00.000Z", "biomarkerId": "", "source": "MANUAL", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -740,7 +740,7 @@ "unit": "ng/mL", "lowerBound": 30, "upperBound": 400, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "Iron", "hidden": false, "analyteKey": null, @@ -758,8 +758,8 @@ "severity": "SEVERE", "status": "ACTIVE", "onsetAt": null, - "reactionEncrypted": "", - "notesEncrypted": "", + "reactionEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -772,7 +772,7 @@ "relationship": "FATHER", "condition": "Diabetes", "ageAtOnset": 55, - "notesEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -789,7 +789,7 @@ "resolvedAt": "2026-07-02T00:00:00.000Z", "parentConditionId": "legacy-episode-parent", "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": null, "createdAt": "", "updatedAt": "", @@ -804,7 +804,7 @@ "onsetAt": "2025-01-01T00:00:00.000Z", "resolvedAt": null, "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, "createdAt": "", @@ -820,7 +820,7 @@ "date": "2026-07-01", "functionalImpact": 2, "feverC": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": null, "createdAt": "", "updatedAt": "", @@ -1350,7 +1350,7 @@ "measuredAt": "2026-07-01T07:00:00.000Z", "source": "MANUAL", "notes": null, - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": null, "aggregationProvenance": null, @@ -1391,6 +1391,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "categoryUpdatedAt": null, "name": "Ramipril", "dose": "5 mg", "active": true, @@ -1454,6 +1455,7 @@ "externalSource": null, "externalId": null, "updatedAt": "", + "categoryUpdatedAt": null, "name": "Vitamin D", "dose": "1000 IU", "active": true, @@ -1471,6 +1473,8 @@ "startsOn": "2026-07-01", "endsOn": "2026-08-01", "note": null, + "noteEncrypted": null, + "updatedAt": "", "createdAt": "" } ], @@ -1534,6 +1538,7 @@ "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-03", "mood": "OKAY", @@ -1555,11 +1560,12 @@ }, { "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "syncedAt": "", "syncVersion": 0, "createdAt": "", "updatedAt": "", + "linkCreatedAt": [], "id": "", "date": "2026-07-02", "mood": "GOOD", @@ -1591,7 +1597,7 @@ { "id": "", "biomarkerId": "", - "noteEncrypted": "", + "noteEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null, @@ -1624,7 +1630,8 @@ "upperBound": 400, "panel": "Iron", "hidden": false, - "context": "fasted", + "context": null, + "contextEncrypted": "", "analyteKey": null, "aliases": [] } @@ -1642,7 +1649,7 @@ "note": null, "noteEncrypted": null, "bodySite": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": null, "createdAt": "", "updatedAt": "", @@ -1655,7 +1662,7 @@ "functionalImpact": 2, "feverC": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "tz": null, "createdAt": "", "updatedAt": "", @@ -1673,7 +1680,7 @@ "resolvedAt": null, "parentConditionId": null, "note": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySite": null, "bodySiteEncrypted": null, "laterality": null, @@ -1694,8 +1701,8 @@ "onsetAt": null, "reaction": null, "note": null, - "reactionEncrypted": "", - "notesEncrypted": "", + "reactionEncrypted": "", + "notesEncrypted": "", "createdAt": "", "updatedAt": "", "deletedAt": null @@ -1707,9 +1714,11 @@ "relationship": "FATHER", "condition": "Diabetes", "ageAtOnset": 55, - "note": "type 2", + "note": null, + "notesEncrypted": "", "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "deletedAt": null } ], "workouts": [ @@ -1800,7 +1809,8 @@ "unit": "mL", "source": "MANUAL", "externalSourceVersion": null, - "createdAt": "" + "createdAt": "", + "updatedAt": "" } ] } diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index 32712c200..1622db7ff 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -25,8 +25,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:03.000Z", + "updatedAt": "2026-05-01T08:21:03.000Z" }, { "id": "", @@ -49,8 +49,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:02.000Z", + "updatedAt": "2026-05-01T08:21:02.000Z" }, { "id": "", @@ -73,8 +73,8 @@ "deviceType": null, "syncVersion": 4, "deletedAt": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:01.000Z", + "updatedAt": "2026-05-01T08:21:01.000Z" }, { "id": "", @@ -97,8 +97,8 @@ "deviceType": null, "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:04.000Z", + "updatedAt": "2026-05-01T08:21:04.000Z" }, { "id": "", @@ -111,7 +111,7 @@ "source": "MANUAL", "measuredAt": "2026-07-01T07:00:00.000Z", "notes": "rt-Measurement-notes", - "notesEncrypted": "", + "notesEncrypted": "", "externalId": null, "externalSourceVersion": "rt-Measurement-externalSourceVersion", "aggregationProvenance": "HEALTHKIT_STATISTICS", @@ -121,8 +121,8 @@ "deviceType": "rt-Measurement-deviceType", "syncVersion": 1, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T08:20:00.000Z", + "updatedAt": "2026-05-01T08:21:00.000Z" } ], "IntradayCumulativeProfile": [ @@ -188,8 +188,8 @@ "basis": "HOUR_BUCKETS", "bucketCount": 3, "timezone": "Europe/Berlin", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T09:51:00.000Z", + "updatedAt": "2026-05-01T09:52:00.000Z" } ], "Medication": [ @@ -226,8 +226,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T11:50:00.000Z", + "updatedAt": "2026-05-01T11:51:00.000Z" } ], "MedicationSchedule": [ @@ -285,21 +285,42 @@ "attributionSource": "USER_PIN", "source": "API", "idempotencyKey": "rt-MedicationIntakeEvent-idempotencyKey", - "createdAt": "", + "createdAt": "2026-05-01T14:51:00.000Z", "injectionSite": "ABDOMEN_LEFT", "doseTaken": "rt-MedicationIntakeEvent-doseTaken", "inventoryConsumption": { "roundTrip": "rt-MedicationIntakeEvent-inventoryConsumption" }, "externalId": "rt-MedicationIntakeEvent-externalId", - "updatedAt": "", + "updatedAt": "2026-05-01T14:56:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "medicationId": "", + "scheduledFor": "2026-07-02T08:00:00.000Z", + "takenAt": "2026-07-02T08:03:00.000Z", + "skipped": false, + "autoMissed": false, + "autoRecorded": false, + "attributionSource": "AUTO", + "source": "WEB", + "idempotencyKey": null, + "createdAt": "2026-05-01T14:51:01.000Z", + "injectionSite": null, + "doseTaken": null, + "inventoryConsumption": null, + "externalId": null, + "updatedAt": "2026-05-01T14:56:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MedicationSideEffect": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "occurredAt": "2026-07-01T21:00:00.000Z", @@ -307,94 +328,94 @@ "entry": "NAUSEA", "severity": 3, "notes": "rt-MedicationSideEffect-notes", - "notesEncrypted": "", - "createdAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-01T16:29:00.000Z" } ], "MedicationPauseEra": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-05-02T08:00:00.000Z", "resumedAt": "2026-05-20T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-01T18:05:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "pausedAt": "2026-07-15T08:00:00.000Z", "resumedAt": null, - "createdAt": "" + "createdAt": "2026-05-01T18:05:01.000Z" } ], "MedicationDoseChange": [ { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-04-01T08:00:00.000Z", "doseValue": 5, "doseUnit": "mg", "note": "rt-MedicationDoseChange-note", - "noteEncrypted": "", - "createdAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-01T19:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "effectiveFrom": "2026-06-01T08:00:00.000Z", "doseValue": 10, "doseUnit": "mg", "note": null, "noteEncrypted": null, - "createdAt": "" + "createdAt": "2026-05-01T19:47:01.000Z" } ], "MedicationCategoryAssignment": [ { "medicationId": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "updatedAt": "" + "updatedAt": "2026-05-01T21:22:00.000Z" } ], "MedicationCategoryLabel": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "key": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "labelEncrypted": "", + "labelEncrypted": "", "sortOrder": 2, "isActive": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-01T23:06:00.000Z", + "updatedAt": "2026-05-01T23:07:00.000Z" } ], "MedicationCourse": [ { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "" + "noteEncrypted": "", + "createdAt": "2026-05-02T00:46:00.000Z", + "updatedAt": "2026-05-02T00:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "userId": "round-trip-owner", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T00:46:01.000Z", + "updatedAt": "2026-05-02T00:47:01.000Z" } ], "MedicationIngredient": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "nutrient": "vitamin_d", @@ -403,13 +424,13 @@ "enteredAmount": 1000, "enteredUnit": "IU", "source": "LABEL_AI", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T02:29:00.000Z", + "updatedAt": "2026-05-02T02:30:00.000Z" } ], "MoodEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", "mood": "GUT", @@ -421,21 +442,45 @@ "stabilityA5": 3, "tags": "[\"round-trip-tag\"]", "note": "rt-MoodEntry-note", - "noteEncrypted": "", + "noteEncrypted": "", "source": "rt-MoodEntry-source", "externalId": "rt-MoodEntry-externalId", "moodLoggedAt": "2026-07-01T20:00:00.000Z", "tz": "rt-MoodEntry-tz", - "syncedAt": "", - "createdAt": "", - "updatedAt": "", + "syncedAt": "2026-05-02T04:17:00.000Z", + "createdAt": "2026-05-02T04:18:00.000Z", + "updatedAt": "2026-05-02T04:19:00.000Z", "syncVersion": 3, "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-07-03", + "mood": "SCHLECHT", + "score": 2, + "moodA1": null, + "stressA2": null, + "energyA3": null, + "connectionA4": null, + "stabilityA5": null, + "tags": null, + "note": null, + "noteEncrypted": null, + "source": "MOODLOG", + "externalId": null, + "moodLoggedAt": "2026-07-03T20:00:00.000Z", + "tz": null, + "syncedAt": "2026-05-02T04:17:01.000Z", + "createdAt": "2026-05-02T04:18:01.000Z", + "updatedAt": "2026-05-02T04:19:01.000Z", + "syncVersion": 0, + "deletedAt": "" } ], "MoodContext": [ { - "moodEntryId": "", + "moodEntryId": "", "userId": "round-trip-owner", "workStatus": "regular", "workMinutes": 3, @@ -454,23 +499,23 @@ "eventType": "rt-MoodContext-eventType", "eventValence": 3, "eventAt": "2026-06-30T12:34:56.789Z", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-02T06:00:00.000Z", + "updatedAt": "2026-05-02T06:01:00.000Z" } ], "MoodEntryTagLink": [ { - "moodEntryId": "", - "moodTagId": "", - "createdAt": "", + "moodEntryId": "", + "moodTagId": "", + "createdAt": "2026-05-02T07:22:00.000Z", "rating": 4 } ], "MoodTag": [ { - "id": "", - "categoryId": "", + "id": "", + "categoryId": "", "key": "round_trip_factor", "labelKey": "mood.tag.roundTripFactor", "icon": "rt-MoodTag-icon", @@ -481,12 +526,12 @@ "scaleMax": 3, "inverse": true, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "LabResult": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "panel": "rt-LabResult-panel", "analyte": "Ferritin", @@ -501,33 +546,71 @@ "sourceValue": 2.5, "sourceUnit": "rt-LabResult-sourceUnit", "takenAt": "2026-06-30T09:00:00.000Z", - "biomarkerId": "", + "biomarkerId": "", "source": "rt-LabResult-source", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-02T10:58:00.000Z", + "updatedAt": "2026-05-02T10:59:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "panel": null, + "analyte": "TSH", + "value": 2.1, + "valueText": null, + "unit": "mU/L", + "referenceLow": null, + "referenceHigh": null, + "sourceReferenceLow": null, + "sourceReferenceHigh": null, + "sourceReferenceText": null, + "sourceValue": null, + "sourceUnit": null, + "takenAt": "2026-05-30T09:00:00.000Z", + "biomarkerId": null, + "source": "MANUAL", + "noteEncrypted": null, + "createdAt": "2026-05-02T10:58:01.000Z", + "updatedAt": "2026-05-02T10:59:01.000Z", + "deletedAt": "" } ], "Biomarker": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Ferritin", "unit": "ng/mL", "lowerBound": 2.5, "upperBound": 2.5, - "contextEncrypted": "", + "contextEncrypted": "", "panel": "rt-Biomarker-panel", "hidden": true, "analyteKey": "rt-Biomarker-analyteKey", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-02T12:30:00.000Z", + "updatedAt": "2026-05-02T12:31:00.000Z" } ], "Allergy": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "substance": "Latex (deleted)", + "category": "OTHER", + "type": "ALLERGY", + "severity": null, + "status": "ACTIVE", + "onsetAt": null, + "reactionEncrypted": null, + "notesEncrypted": null, + "createdAt": "2026-05-02T14:10:01.000Z", + "updatedAt": "2026-05-02T14:11:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "substance": "Penicillin", "category": "FOOD", @@ -535,29 +618,40 @@ "severity": "MILD", "status": "INACTIVE", "onsetAt": "2026-06-30T12:34:56.789Z", - "reactionEncrypted": "", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "reactionEncrypted": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T14:10:00.000Z", + "updatedAt": "2026-05-02T14:11:00.000Z", "deletedAt": null } ], "FamilyHistoryEntry": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "relationship": "FATHER", + "condition": "Hypertension (deleted)", + "ageAtOnset": null, + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:01.000Z", + "updatedAt": "2026-05-02T15:47:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "relationship": "MOTHER", "condition": "Type 2 diabetes", "ageAtOnset": 3, - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "", + "notesEncrypted": "", + "createdAt": "2026-05-02T15:46:00.000Z", + "updatedAt": "2026-05-02T15:47:00.000Z", "deletedAt": null } ], "IllnessEpisode": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Cold", "type": "INFECTION", @@ -565,15 +659,31 @@ "onsetAt": "2026-06-20T00:00:00.000Z", "resolvedAt": "2026-06-30T12:34:56.789Z", "parentConditionId": null, - "noteEncrypted": "", + "noteEncrypted": "", "bodySiteEncrypted": null, "laterality": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:00.000Z", + "updatedAt": "2026-05-02T17:32:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", + "userId": "round-trip-owner", + "label": "Iron deficiency flare", + "type": "CHRONIC", + "lifecycle": "FLARE", + "onsetAt": "2026-06-25T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": "", + "noteEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:03.000Z", + "updatedAt": "2026-05-02T17:32:03.000Z", + "deletedAt": null + }, + { + "id": "", "userId": "round-trip-owner", "label": "Iron deficiency", "type": "CHRONIC", @@ -582,162 +692,215 @@ "resolvedAt": null, "parentConditionId": null, "noteEncrypted": null, - "bodySiteEncrypted": "", + "bodySiteEncrypted": "", "laterality": "BOTH", - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-02T17:31:01.000Z", + "updatedAt": "2026-05-02T17:32:01.000Z", "deletedAt": null - } - ], - "IllnessDayLog": [ - { - "id": "", - "userId": "round-trip-owner", - "episodeId": "", - "date": "2026-06-21", - "functionalImpact": 3, - "feverC": 2.5, - "noteEncrypted": "", - "tz": "rt-IllnessDayLog-tz", - "createdAt": "", - "updatedAt": "", - "deletedAt": null - } - ], - "IllnessSymptomLink": [ - { - "dayLogId": "", - "symptomId": "", - "severity": 2, - "createdAt": "" - } - ], - "SymptomDefinition": [ - { - "id": "", - "userId": "round-trip-owner", - "labelEncrypted": "", - "icon": "Zap", - "sortOrder": 0, - "isActive": true, - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "labelEncrypted": "", - "icon": "Brain", - "sortOrder": 1, - "isActive": false, - "createdAt": "", - "updatedAt": "" - } - ], - "SymptomEvent": [ - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T09:00:00.000Z", - "intensity": 4, - "noteEncrypted": null, - "episodeId": "", - "createdAt": "", - "updatedAt": "" - }, - { - "id": "", - "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-06-21T11:00:00.000Z", - "intensity": 8, - "noteEncrypted": "", - "episodeId": null, - "createdAt": "", - "updatedAt": "" }, { "id": "", "userId": "round-trip-owner", - "definitionId": "", - "occurredAt": "2026-07-10T18:30:00.000Z", - "intensity": 2, + "label": "Sprained ankle (deleted)", + "type": "INJURY", + "lifecycle": "ACUTE", + "onsetAt": "2026-04-10T00:00:00.000Z", + "resolvedAt": null, + "parentConditionId": null, "noteEncrypted": null, - "episodeId": null, - "createdAt": "", - "updatedAt": "" + "bodySiteEncrypted": null, + "laterality": null, + "createdAt": "2026-05-02T17:31:02.000Z", + "updatedAt": "2026-05-02T17:32:02.000Z", + "deletedAt": "" } ], - "LifeEvent": [ + "IllnessDayLog": [ { "id": "", "userId": "round-trip-owner", - "category": "HOME", - "startDate": "2023-09-01", - "endDate": null, - "precision": "MONTH", - "titleEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "episodeId": "", + "date": "2026-06-21", + "functionalImpact": 3, + "feverC": 2.5, + "noteEncrypted": "", + "tz": "rt-IllnessDayLog-tz", + "createdAt": "2026-05-02T19:08:00.000Z", + "updatedAt": "2026-05-02T19:09:00.000Z", "deletedAt": null }, { "id": "", "userId": "round-trip-owner", + "episodeId": "", + "date": "2026-06-22", + "functionalImpact": null, + "feverC": null, + "noteEncrypted": null, + "tz": null, + "createdAt": "2026-05-02T19:08:01.000Z", + "updatedAt": "2026-05-02T19:09:01.000Z", + "deletedAt": "" + } + ], + "IllnessSymptomLink": [ + { + "dayLogId": "", + "symptomId": "", + "severity": 2, + "createdAt": "2026-05-02T20:43:00.000Z" + } + ], + "SymptomDefinition": [ + { + "id": "", + "userId": "round-trip-owner", + "labelEncrypted": "", + "icon": "Zap", + "sortOrder": 0, + "isActive": true, + "createdAt": "2026-05-02T22:26:00.000Z", + "updatedAt": "2026-05-02T22:27:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "labelEncrypted": "", + "icon": "Brain", + "sortOrder": 1, + "isActive": false, + "createdAt": "2026-05-02T22:26:01.000Z", + "updatedAt": "2026-05-02T22:27:01.000Z" + } + ], + "SymptomEvent": [ + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-06-21T09:00:00.000Z", + "intensity": 4, + "noteEncrypted": null, + "episodeId": "", + "createdAt": "2026-05-03T00:07:00.000Z", + "updatedAt": "2026-05-03T00:08:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-06-21T11:00:00.000Z", + "intensity": 8, + "noteEncrypted": "", + "episodeId": null, + "createdAt": "2026-05-03T00:07:01.000Z", + "updatedAt": "2026-05-03T00:08:01.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "definitionId": "", + "occurredAt": "2026-07-10T18:30:00.000Z", + "intensity": 2, + "noteEncrypted": null, + "episodeId": null, + "createdAt": "2026-05-03T00:07:02.000Z", + "updatedAt": "2026-05-03T00:08:02.000Z" + } + ], + "LifeEvent": [ + { + "id": "", + "userId": "round-trip-owner", + "category": "HOME", + "startDate": "2023-09-01", + "endDate": null, + "precision": "MONTH", + "titleEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T01:48:00.000Z", + "updatedAt": "2026-05-03T01:49:00.000Z", + "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "category": "OTHER", + "startDate": "2025-12-01", + "endDate": null, + "precision": "DAY", + "titleEncrypted": "", + "noteEncrypted": null, + "createdAt": "2026-05-03T01:48:02.000Z", + "updatedAt": "2026-05-03T01:49:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", "category": "WORK", "startDate": "2026-03-02", "endDate": "2026-05-29", "precision": "DAY", - "titleEncrypted": "", + "titleEncrypted": "", "noteEncrypted": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T01:48:01.000Z", + "updatedAt": "2026-05-03T01:49:01.000Z", "deletedAt": null } ], "UserHealthProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "aboutMeEncrypted": "", - "conditionsEncrypted": "", - "allergiesEncrypted": "", - "coachFocusEncrypted": "", - "pendingQuestionsEncrypted": "", + "aboutMeEncrypted": "", + "conditionsEncrypted": "", + "allergiesEncrypted": "", + "coachFocusEncrypted": "", + "pendingQuestionsEncrypted": "", "aiIncludedSections": [ "ABOUT_ME" ], "emergencyBloodType": "A_POS", "organDonorStatus": "YES", "advanceDirectiveStatus": "EXISTS", - "emergencyContactsEncrypted": "", - "emergencyImplantsEncrypted": "", - "emergencyNoteEncrypted": "", + "emergencyContactsEncrypted": "", + "emergencyImplantsEncrypted": "", + "emergencyNoteEncrypted": "", "lifeConfirmedJson": { "roundTrip": "rt-UserHealthProfile-lifeConfirmedJson" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T03:35:00.000Z", + "updatedAt": "2026-05-03T03:36:00.000Z" } ], "HealthProfileFactRevision": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "SMOKING_STATUS", - "valueEncrypted": "", + "valueEncrypted": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-06-30T12:34:56.789Z", "provenance": "USER_CORRECTION", + "supersededByRevisionId": "", + "createdAt": "2026-05-03T05:08:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "kind": "SMOKING_STATUS", + "valueEncrypted": "", + "validFrom": "2026-02-01T00:00:00.000Z", + "validUntil": null, + "provenance": "USER_REPORTED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-03T05:08:01.000Z" } ], "CycleProfile": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "goal": "AVOID_PREGNANCY", "cycleTrackingEnabled": true, @@ -749,13 +912,30 @@ "rawChartMode": true, "discreetNotifications": true, "sensitiveCategoryEncryption": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T06:52:00.000Z", + "updatedAt": "2026-05-03T06:53:00.000Z" } ], "MenstrualCycle": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "startDate": "2026-05-04", + "endDate": null, + "periodEndDate": null, + "lengthDays": null, + "ovulationDate": null, + "ovulationConfirmed": false, + "isPredicted": false, + "tz": null, + "syncVersion": 0, + "deletedAt": "", + "absorbedIntoId": null, + "createdAt": "2026-05-03T08:33:02.000Z", + "updatedAt": "2026-05-03T08:34:02.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-01", "endDate": "rt-MenstrualCycle-endDate", @@ -768,11 +948,11 @@ "syncVersion": 3, "deletedAt": null, "absorbedIntoId": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T08:33:00.000Z", + "updatedAt": "2026-05-03T08:34:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-03", "endDate": null, @@ -784,17 +964,17 @@ "tz": null, "syncVersion": 0, "deletedAt": "2026-06-04T07:00:00.000Z", - "absorbedIntoId": "", - "createdAt": "", - "updatedAt": "" + "absorbedIntoId": "", + "createdAt": "2026-05-03T08:33:01.000Z", + "updatedAt": "2026-05-03T08:34:01.000Z" } ], "CycleDayLog": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-02", - "cycleId": "", + "cycleId": "", "flow": "HEAVY", "intermenstrualBleeding": true, "basalBodyTempC": 2.5, @@ -809,20 +989,49 @@ "pregnancyTest": "NEGATIVE", "progesteroneTest": "NEGATIVE", "contraceptive": "NONE", - "sensitiveEncrypted": "", - "notesEncrypted": "", + "sensitiveEncrypted": "", + "notesEncrypted": "", "source": "WITHINGS", "externalId": "rt-CycleDayLog-externalId", "tz": "rt-CycleDayLog-tz", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T10:25:00.000Z", + "updatedAt": "2026-05-03T10:26:00.000Z" + }, + { + "id": "", + "userId": "round-trip-owner", + "date": "2026-06-03", + "cycleId": "", + "flow": "LIGHT", + "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": "", + "createdAt": "2026-05-03T10:25:01.000Z", + "updatedAt": "2026-05-03T10:26:01.000Z" } ], "CycleSymptom": [ { - "id": "", + "id": "", "categoryId": "csc_physical", "key": "custom:round-trip-cramps", "labelKey": "cycle.symptom.custom", @@ -830,20 +1039,20 @@ "sortOrder": 3, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "CycleSymptomLink": [ { - "dayLogId": "", - "symptomId": "", + "dayLogId": "", + "symptomId": "", "severity": 3, - "createdAt": "" + "createdAt": "2026-05-03T13:23:00.000Z" } ], "CustomMetric": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "name": "Grip strength", "unit": "kg", @@ -852,40 +1061,66 @@ "decimals": 3, "description": "rt-CustomMetric-description", "correlationEnabled": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-03T15:09:00.000Z", + "updatedAt": "2026-05-03T15:10:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "name": "Plank (deleted)", + "unit": "s", + "targetLow": null, + "targetHigh": null, + "decimals": null, + "description": null, + "correlationEnabled": false, + "createdAt": "2026-05-03T15:09:01.000Z", + "updatedAt": "2026-05-03T15:10:01.000Z", + "deletedAt": "" } ], "CustomMetricEntry": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", + "value": 40, + "unit": "kg", + "measuredAt": "2026-06-28T18:00:00.000Z", + "note": null, + "noteEncrypted": null, + "createdAt": "2026-05-03T16:48:02.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "customMetricId": "", "value": 44, "unit": "kg", "measuredAt": "2026-07-01T18:00:00.000Z", "note": "left hand, after the long ride", "noteEncrypted": null, - "createdAt": "", + "createdAt": "2026-05-03T16:48:00.000Z", "deletedAt": null }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "customMetricId": "", + "customMetricId": "", "value": 45, "unit": "kg", "measuredAt": "2026-07-02T18:00:00.000Z", "note": null, - "noteEncrypted": "", - "createdAt": "", + "noteEncrypted": "", + "createdAt": "2026-05-03T16:48:01.000Z", "deletedAt": null } ], "CorrelationPattern": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "canonicalKey": "p1:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "family": "sleep", @@ -903,13 +1138,13 @@ "dismissedEvidenceHash": "abababababababababababababababababababababababababababababababab", "dismissedEffectSize": 2.5, "dismissedSampleSize": 3, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T18:38:00.000Z", + "updatedAt": "2026-05-03T18:39:00.000Z" } ], "HealthScoreRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "dayKey": "2026-07-01", "timezone": "Europe/Berlin", @@ -927,13 +1162,13 @@ "inputFingerprint": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "configVersion": 3, "configChangedAt": "2026-06-30T12:34:56.789Z", - "computedAt": "", - "createdAt": "" + "computedAt": "2026-05-03T20:12:00.000Z", + "createdAt": "2026-05-03T20:13:00.000Z" } ], "Workout": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "sportType": "RUNNING", "startedAt": "2026-06-29T06:00:00.000Z", @@ -953,8 +1188,8 @@ "metadata": { "roundTrip": "rt-Workout-metadata" }, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T21:58:00.000Z", + "updatedAt": "2026-05-03T21:59:00.000Z" } ], "NutrientIntakeDay": [ @@ -966,20 +1201,20 @@ "unit": "ml", "source": "MANUAL", "externalSourceVersion": "rt-NutrientIntakeDay-externalSourceVersion", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-03T23:27:00.000Z", + "updatedAt": "2026-05-03T23:28:00.000Z" } ], "InboundDocument": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "LAB_RESULT", "title": "June labs", "filename": "june-labs.pdf", "mimeType": "application/pdf", "byteSize": 57, - "contentEncrypted": "", + "contentEncrypted": "", "contentSha256": "0a738470f0945033965858e9895ee721ce981a25f013514d587d0a7a1946be7a", "contentCodec": "binary2", "status": "EXTRACTING", @@ -987,7 +1222,7 @@ "reportDate": "2026-06-30T12:34:56.789Z", "documentDate": "2026-06-30T12:34:56.789Z", "errorReason": "rt-InboundDocument-errorReason", - "summaryEncrypted": "", + "summaryEncrypted": "", "summaryGeneratedAt": "2026-06-30T12:34:56.789Z", "summaryState": "PENDING", "lastIndexAttemptAt": "2026-06-30T12:34:56.789Z", @@ -996,76 +1231,155 @@ "sourceId": "4711", "sourceInstance": "https://paperless.example.test", "aiReadDeferred": true, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T01:24:00.000Z", + "updatedAt": "2026-05-04T01:25:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "kind": "OTHER", + "title": "Deleted scan", + "filename": "deleted-scan.pdf", + "mimeType": "application/pdf", + "byteSize": 57, + "contentEncrypted": "", + "contentSha256": null, + "contentCodec": "binary2", + "status": "STORED", + "providerType": null, + "reportDate": null, + "documentDate": null, + "errorReason": null, + "summaryEncrypted": null, + "summaryGeneratedAt": null, + "summaryState": "NONE", + "lastIndexAttemptAt": null, + "lastIndexOutcome": null, + "sourceSystem": null, + "sourceId": null, + "sourceInstance": null, + "aiReadDeferred": false, + "createdAt": "2026-05-04T01:24:01.000Z", + "updatedAt": "2026-05-04T01:25:01.000Z", + "deletedAt": "" } ], "Practitioner": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Closed practice (deleted)", + "specialty": null, + "practice": null, + "location": null, + "phone": null, + "locationEncrypted": null, + "phoneEncrypted": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T02:50:01.000Z", + "updatedAt": "2026-05-04T02:51:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip practice", "specialty": "Internal medicine", "practice": "rt-Practitioner-practice", "location": "rt-Practitioner-location", "phone": "rt-Practitioner-phone", - "locationEncrypted": "", - "phoneEncrypted": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "locationEncrypted": "", + "phoneEncrypted": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T02:50:00.000Z", + "updatedAt": "2026-05-04T02:51:00.000Z", "deletedAt": null } ], "Encounter": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-02T08:00:00.000Z", + "status": "DONE", + "kind": "ROUTINE", + "practitionerId": "", + "reasonEncrypted": null, + "outcomeEncrypted": null, + "bodySiteEncrypted": null, + "laterality": null, + "reminderId": null, + "createdAt": "2026-05-04T04:31:01.000Z", + "updatedAt": "2026-05-04T04:32:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-06-30T08:00:00.000Z", "status": "PLANNED", "kind": "ROUTINE", - "practitionerId": "", - "reasonEncrypted": "", - "outcomeEncrypted": "", - "bodySiteEncrypted": "", + "practitionerId": "", + "reasonEncrypted": "", + "outcomeEncrypted": "", + "bodySiteEncrypted": "", "laterality": "LEFT", - "reminderId": "", - "createdAt": "", - "updatedAt": "", + "reminderId": "", + "createdAt": "2026-05-04T04:31:00.000Z", + "updatedAt": "2026-05-04T04:32:00.000Z", "deletedAt": null } ], "EncounterDocumentLink": [ { - "id": "", - "encounterId": "", - "documentId": "", + "id": "", + "encounterId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T06:04:00.000Z" } ], "EncounterLabLink": [ { - "id": "", - "encounterId": "", - "labResultId": "", + "id": "", + "encounterId": "", + "labResultId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T07:44:00.000Z" } ], "EncounterConditionLink": [ { - "id": "", - "encounterId": "", - "episodeId": "", + "id": "", + "encounterId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T09:24:00.000Z" } ], "VaccinationRecord": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "occurredAt": "2026-04-14T00:00:00.000Z", + "antigenSlug": "influenza", + "vaccineName": "Influenza (deleted)", + "doseNumber": null, + "seriesDoses": null, + "lotNumber": null, + "site": null, + "practitionerId": null, + "encounterId": null, + "reminderId": null, + "customVaccineId": null, + "noteEncrypted": null, + "createdAt": "2026-05-04T11:14:01.000Z", + "updatedAt": "2026-05-04T11:15:01.000Z", + "deletedAt": "" + }, + { + "id": "", "userId": "round-trip-owner", "occurredAt": "2026-05-14T00:00:00.000Z", "antigenSlug": "tdap", @@ -1074,28 +1388,41 @@ "seriesDoses": 3, "lotNumber": "RT-4471", "site": "LEFT_ARM", - "practitionerId": "", - "encounterId": "", - "reminderId": "", - "customVaccineId": "", - "noteEncrypted": "", - "createdAt": "", - "updatedAt": "", + "practitionerId": "", + "encounterId": "", + "reminderId": "", + "customVaccineId": "", + "noteEncrypted": "", + "createdAt": "2026-05-04T11:14:00.000Z", + "updatedAt": "2026-05-04T11:15:00.000Z", "deletedAt": null } ], "VaccinationDocumentLink": [ { - "id": "", - "vaccinationId": "", - "documentId": "", + "id": "", + "vaccinationId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-04T12:44:00.000Z" } ], "CustomVaccine": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "name": "Deleted combo", + "components": [ + "rabies" + ], + "typicalSeriesDoses": null, + "boosterIntervalMonths": null, + "deletedAt": "", + "createdAt": "2026-05-04T14:27:01.000Z", + "updatedAt": "2026-05-04T14:28:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "name": "Round-trip travel combo", "components": [ @@ -1105,13 +1432,13 @@ "typicalSeriesDoses": 2, "boosterIntervalMonths": 36, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-04T14:27:00.000Z", + "updatedAt": "2026-05-04T14:28:00.000Z" } ], "MeasurementReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "label": "Blutdruck messen", "measurementType": "BLOOD_PRESSURE_SYS", @@ -1130,53 +1457,77 @@ "lastSkippedAt": "2026-06-24T06:00:00.000Z", "skipCount": 2, "lastNotifiedAt": null, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-04T16:19:00.000Z", + "updatedAt": "2026-05-04T16:20:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "label": "Gewicht (deleted)", + "measurementType": "WEIGHT", + "intervalDays": 14, + "rrule": null, + "anchorDate": null, + "endsOn": null, + "origin": "VORSORGE", + "notifyHour": 9, + "location": null, + "nextDueAt": null, + "lastSatisfiedAt": null, + "enabled": true, + "vaccinationAntigen": null, + "snoozedUntil": null, + "lastSkippedAt": null, + "skipCount": 0, + "lastNotifiedAt": null, + "createdAt": "2026-05-04T16:19:01.000Z", + "updatedAt": "2026-05-04T16:20:01.000Z", + "deletedAt": "" } ], "MeasurementReminderEvent": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SATISFIED", "occurredAt": "2026-07-01T06:05:00.000Z", "onTime": true, "source": "manual", - "createdAt": "" + "createdAt": "2026-05-04T17:47:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", - "reminderId": "", + "reminderId": "", "kind": "SKIPPED", "occurredAt": "2026-06-24T06:00:00.000Z", "onTime": false, "source": "skip", - "createdAt": "" + "createdAt": "2026-05-04T17:47:01.000Z" } ], "CoachConversation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "title": "How is my blood pressure trending?", "titleEncrypted": null, - "createdAt": "", - "updatedAt": "", - "summaryEncrypted": "", + "createdAt": "2026-05-04T19:24:00.000Z", + "updatedAt": "2026-05-04T19:25:00.000Z", + "summaryEncrypted": "", "summaryUpdatedAt": "2026-07-20T10:00:00.000Z", "summaryTurnCount": 4, "documentScoped": false }, { - "id": "", + "id": "", "userId": "round-trip-owner", "title": null, - "titleEncrypted": "", - "createdAt": "", - "updatedAt": "", + "titleEncrypted": "", + "createdAt": "2026-05-04T19:24:01.000Z", + "updatedAt": "2026-05-04T19:25:01.000Z", "summaryEncrypted": null, "summaryUpdatedAt": null, "summaryTurnCount": 0, @@ -1185,24 +1536,24 @@ ], "CoachMessage": [ { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "assistant", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": "anthropic", "promptVersion": null, "tokensUsed": 812, "model": "claude-opus-5", - "resultsEncrypted": "", - "trailEncrypted": "", + "resultsEncrypted": "", + "trailEncrypted": "", "createdAt": "2026-07-20T09:59:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": "rt-CoachMessage-metricSourceJson", "providerType": null, "promptVersion": "rt-CoachMessage-promptVersion", @@ -1213,10 +1564,10 @@ "createdAt": "2026-07-20T09:58:00.000Z" }, { - "id": "", - "conversationId": "", + "id": "", + "conversationId": "", "role": "user", - "encryptedContent": "", + "encryptedContent": "", "metricSourceJson": null, "providerType": null, "promptVersion": null, @@ -1229,67 +1580,114 @@ ], "CoachConversationDocument": [ { - "conversationId": "", - "documentId": "", + "conversationId": "", + "documentId": "", "addedAt": "2026-07-21T07:59:00.000Z" } ], "CoachFact": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "factEncrypted": "", + "factEncrypted": "", + "category": "preference", + "confidence": 50, + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:01.000Z", + "updatedAt": "2026-05-05T00:27:01.000Z", + "deletedAt": "", + "source": "user", + "lastUsedAt": null, + "sourceMessageId": null + }, + { + "id": "", + "userId": "round-trip-owner", + "factEncrypted": "", "category": "constraint", "confidence": 80, - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T00:26:00.000Z", + "updatedAt": "2026-05-05T00:27:00.000Z", "deletedAt": null, "source": "user", "lastUsedAt": "2026-07-20T10:00:00.000Z", - "sourceMessageId": "" + "sourceMessageId": "" } ], "CoachPlan": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metric": "BLOOD_PRESSURE", - "ifCueEncrypted": "", - "thenActionEncrypted": "", - "targetEncrypted": "", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": "", "status": "active", "reviewDate": "2026-10-01T00:00:00.000Z", - "outcomeEncrypted": "", - "sourceConversationId": "", - "createdAt": "", - "updatedAt": "", + "outcomeEncrypted": "", + "sourceConversationId": "", + "createdAt": "2026-05-05T02:10:00.000Z", + "updatedAt": "2026-05-05T02:11:00.000Z", "deletedAt": null + }, + { + "id": "", + "userId": "round-trip-owner", + "metric": "WEIGHT", + "ifCueEncrypted": "", + "thenActionEncrypted": "", + "targetEncrypted": null, + "status": "active", + "reviewDate": null, + "outcomeEncrypted": null, + "sourceConversationId": null, + "createdAt": "2026-05-05T02:10:01.000Z", + "updatedAt": "2026-05-05T02:11:01.000Z", + "deletedAt": "" } ], "CoachReminder": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "noteEncrypted": "", + "noteEncrypted": "", + "metric": null, + "relatedPlanId": "", + "triggerKind": "date", + "dueAt": "2026-09-15T07:00:00.000Z", + "contextCue": null, + "status": "active", + "source": "extractor", + "sourceConversationId": null, + "lastSurfacedAt": null, + "surfaceCount": 0, + "createdAt": "2026-05-05T03:53:01.000Z", + "updatedAt": "2026-05-05T03:54:01.000Z", + "deletedAt": "" + }, + { + "id": "", + "userId": "round-trip-owner", + "noteEncrypted": "", "metric": "BLOOD_PRESSURE", - "relatedPlanId": "", + "relatedPlanId": "", "triggerKind": "rt-CoachReminder-triggerKind", "dueAt": "2026-09-01T07:00:00.000Z", "contextCue": "rt-CoachReminder-contextCue", "status": "rt-CoachReminder-status", "source": "extractor", - "sourceConversationId": "", + "sourceConversationId": "", "lastSurfacedAt": "2026-06-30T12:34:56.789Z", "surfaceCount": 2, - "createdAt": "", - "updatedAt": "", + "createdAt": "2026-05-05T03:53:00.000Z", + "updatedAt": "2026-05-05T03:54:00.000Z", "deletedAt": null } ], "MedicationInventoryItem": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "ACTIVE", @@ -1297,18 +1695,18 @@ "unitsTotal": "decimal:30", "unitsRemaining": "decimal:21", "firstUseAt": "2026-07-01T08:00:00.000Z", - "expiresAt": "2027-01-31T00:00:00.000Z", + "expiresAt": "2026-05-05T05:28:00.000Z", "printedExpiry": "2026-06-30T12:34:56.789Z", "purchasedAt": "2026-06-30T12:34:56.789Z", "manufacturer": "Fixture Pharma", "doseStrength": "10 mg", "notes": "rt-MedicationInventoryItem-notes", - "notesEncrypted": "", - "createdAt": "", - "updatedAt": "" + "notesEncrypted": "", + "createdAt": "2026-05-05T05:35:00.000Z", + "updatedAt": "2026-05-05T05:36:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "medicationId": "", "state": "USED_UP", @@ -1323,20 +1721,20 @@ "doseStrength": null, "notes": null, "notesEncrypted": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T05:35:01.000Z", + "updatedAt": "2026-05-05T05:36:01.000Z" } ], "MedicationInventoryEvent": [ { - "id": "", + "id": "", "medicationId": "", "delta": -8, "reason": "consumed", "occurredAt": "2026-07-09T07:00:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "delta": 30, "reason": "purchased", @@ -1345,26 +1743,26 @@ ], "MoodTagCategory": [ { - "id": "", + "id": "", "key": "round_trip_own_category", "labelKey": "mood.category.roundTripOwn", "icon": "rt-MoodTagCategory-icon", "sortOrder": 90, "isActive": false, "userId": "round-trip-owner", - "labelEncrypted": "" + "labelEncrypted": "" } ], "MoodTagHidden": [ { "userId": "round-trip-owner", "moodTagId": "mt_grateful", - "createdAt": "" + "createdAt": "2026-05-05T10:22:00.000Z" } ], "ReminderPhaseConfig": [ { - "id": "", + "id": "", "medicationId": "", "greenValue": 45, "greenMode": "PERCENT", @@ -1378,12 +1776,32 @@ ], "MentalHealthAssessment": [ { - "id": "", + "id": "", + "userId": "round-trip-owner", + "instrument": "GAD7", + "locale": "en", + "version": "standard", + "responsesEncrypted": "", + "totalScore": 4, + "severityBand": "minimal", + "item9Flagged": false, + "crisisShownAt": null, + "takenAt": "2026-06-05T18:00:00.000Z", + "tz": "Europe/Berlin", + "source": "WEB", + "externalId": null, + "syncVersion": 0, + "deletedAt": "", + "createdAt": "2026-05-05T13:56:01.000Z", + "updatedAt": "2026-05-05T13:57:01.000Z" + }, + { + "id": "", "userId": "round-trip-owner", "instrument": "PHQ9", "locale": "de", "version": "rt-MentalHealthAssessment-version", - "responsesEncrypted": "", + "responsesEncrypted": "", "totalScore": 14, "severityBand": "moderate", "item9Flagged": true, @@ -1394,59 +1812,59 @@ "externalId": "rt-MentalHealthAssessment-externalId", "syncVersion": 3, "deletedAt": null, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T13:56:00.000Z", + "updatedAt": "2026-05-05T13:57:00.000Z" } ], "ConsentReceipt": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "ai-processing", "artefact": "JVBERi0xLjQKJWZpeHR1cmU=", "signedAt": "2026-06-01T09:00:00.000Z", "revokedAt": null, - "createdAt": "" + "createdAt": "2026-05-05T15:26:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "kind": "research-sharing", "artefact": "eyJhbGciOiJub25lIn0.e30.", "signedAt": "2026-06-02T09:00:00.000Z", "revokedAt": "2026-07-10T11:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T15:26:01.000Z" } ], "DocumentConditionLink": [ { - "id": "", - "documentId": "", - "episodeId": "", + "id": "", + "documentId": "", + "episodeId": "", "userId": "round-trip-owner", "createdAt": "2026-07-02T09:00:00.000Z" } ], "ExtractedFact": [ { - "id": "", - "documentId": "", + "id": "", + "documentId": "", "userId": "round-trip-owner", "factType": "OBSERVATION", "status": "APPROVED", "confidence": 0.94, "needsReview": false, - "dataEncrypted": "", - "provenanceEncrypted": "", - "committedRecordId": "", + "dataEncrypted": "", + "provenanceEncrypted": "", + "committedRecordId": "", "committedRecordType": "labResult", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-05T18:51:00.000Z", + "updatedAt": "2026-05-05T18:52:00.000Z" } ], "PersonalRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_10km_time", @@ -1457,10 +1875,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-10km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:02.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WALKING_RUNNING_DISTANCE", "metricSlot": "running_5km_time", @@ -1471,10 +1889,10 @@ "sourceMeasurementId": null, "source": "APPLE_HEALTH", "externalId": "workout-pr-5km", - "createdAt": "" + "createdAt": "2026-05-05T20:31:01.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "metricType": "WEIGHT", "metricSlot": null, @@ -1485,31 +1903,31 @@ "sourceMeasurementId": "", "source": "MANUAL", "externalId": null, - "createdAt": "" + "createdAt": "2026-05-05T20:31:00.000Z" } ], "UserAchievement": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "intake-total-10", "unlockedAt": "2026-02-21T10:15:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "achievementId": "retired-badge-from-an-older-release", "unlockedAt": "2025-11-05T08:00:00.000Z", - "createdAt": "" + "createdAt": "2026-05-05T22:04:01.000Z" } ], "EnvironmentContext": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", - "locationEncrypted": "", + "locationEncrypted": "", "source": "TRAVEL", "tempMin": 19.4, "tempMax": 28.1, @@ -1546,14 +1964,14 @@ "aqHours": 3, "aqFetchedAt": "2026-06-30T12:34:56.789Z", "fetchedAt": "2026-06-16T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:00.000Z", + "updatedAt": "2026-05-06T00:21:00.000Z" }, { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-07-01", - "locationEncrypted": "", + "locationEncrypted": "", "source": "HOME", "tempMin": 13.1, "tempMax": 22.7, @@ -1590,24 +2008,24 @@ "aqHours": null, "aqFetchedAt": null, "fetchedAt": "2026-07-02T03:15:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T00:20:01.000Z", + "updatedAt": "2026-05-06T00:21:01.000Z" } ], "EnvironmentTravelLocation": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "startDate": "2026-06-10", "endDate": "2026-06-20", - "locationEncrypted": "", - "createdAt": "", - "updatedAt": "" + "locationEncrypted": "", + "createdAt": "2026-05-06T01:25:00.000Z", + "updatedAt": "2026-05-06T01:26:00.000Z" } ], "DawarichDay": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "date": "2026-06-15", "totalDistanceM": 48300, @@ -1615,16 +2033,16 @@ "trainDistanceM": 30500, "placesVisited": 4, "awayFromHomeMinutes": 1440, - "countryCodeEncrypted": "", + "countryCodeEncrypted": "", "isTravelDay": true, "fetchedAt": "2026-06-16T03:20:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T03:11:00.000Z", + "updatedAt": "2026-05-06T03:12:00.000Z" } ], "MedicationScheduleRevision": [ { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-01-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1637,10 +2055,10 @@ ], "source": "ARCHIVED", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:00.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-02-01T00:00:00.000Z", "validUntil": "2026-03-01T00:00:00.000Z", @@ -1652,11 +2070,11 @@ } ], "source": "ARCHIVED", - "supersededByRevisionId": "", - "createdAt": "" + "supersededByRevisionId": "", + "createdAt": "2026-05-06T04:47:02.000Z" }, { - "id": "", + "id": "", "medicationId": "", "validFrom": "2026-03-01T00:00:00.000Z", "validUntil": "2026-05-01T00:00:00.000Z", @@ -1669,28 +2087,28 @@ ], "source": "MANUAL", "supersededByRevisionId": null, - "createdAt": "" + "createdAt": "2026-05-06T04:47:01.000Z" } ], "MedicationEfficacyTarget": [ { - "id": "", + "id": "", "medicationId": "", "measurementType": "WEIGHT", - "biomarkerId": "", + "biomarkerId": "", "primary": false, - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T06:25:00.000Z", + "updatedAt": "2026-05-06T06:26:00.000Z" } ], "EcgRecording": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "source": "MANUAL", "externalRecordingId": "round-trip-signal-1", "recordedAt": "2026-06-15T14:22:00.000Z", - "waveformEncrypted": "", + "waveformEncrypted": "", "samplingFrequency": 300, "sampleCount": 8, "durationSeconds": 0.02666666666666667, @@ -1698,13 +2116,13 @@ "averageHeartRate": 88, "rhythmClassification": "IRREGULAR", "measurementId": "", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T08:13:00.000Z", + "updatedAt": "2026-05-06T08:14:00.000Z" } ], "OnboardingRecord": [ { - "id": "", + "id": "", "userId": "round-trip-owner", "needsJson": { "areas": [ @@ -1743,80 +2161,80 @@ }, "modulesDerivedAt": "2026-07-01T08:00:00.000Z", "completedAt": "2026-07-01T08:00:00.000Z", - "createdAt": "", - "updatedAt": "" + "createdAt": "2026-05-06T09:47:00.000Z", + "updatedAt": "2026-05-06T09:48:00.000Z" } ], "BiomarkerAlias": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "biomarkerId": "", + "biomarkerId": "", "name": "Ferr.", "nameFolded": "ferr.", "source": "IMPORT_CHOICE", - "createdAt": "" + "createdAt": "2026-05-06T11:26:00.000Z" } ], "BiomarkerMerge": [ { - "id": "", + "id": "", "userId": "round-trip-owner", - "targetBiomarkerId": "", + "targetBiomarkerId": "", "sourceBiomarkerId": "merged-away-marker", "sourceName": "Ferritin (Serum)", "readingCount": 3, - "mergedAt": "" + "mergedAt": "2026-05-06T13:06:00.000Z" } ], "LabResultDocumentLink": [ { - "id": "", - "labResultId": "", - "documentId": "", + "id": "", + "labResultId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T14:44:00.000Z" } ], "EncounterMedicationLink": [ { - "id": "", - "encounterId": "", + "id": "", + "encounterId": "", "medicationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T16:24:00.000Z" } ], "EncounterVaccinationLink": [ { - "id": "", - "encounterId": "", - "vaccinationId": "", + "id": "", + "encounterId": "", + "vaccinationId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T18:04:00.000Z" } ], "MedicationDocumentLink": [ { - "id": "", + "id": "", "medicationId": "", - "documentId": "", + "documentId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T19:44:00.000Z" } ], "MedicationConditionLink": [ { - "id": "", + "id": "", "medicationId": "", - "episodeId": "", + "episodeId": "", "userId": "round-trip-owner", - "createdAt": "" + "createdAt": "2026-05-06T21:24:00.000Z" } ], "AccountGrant": [ { - "id": "", + "id": "", "grantorId": "round-trip-owner", "granteeId": "parity-delegate", "access": "WRITE", @@ -1825,13 +2243,13 @@ "measurements" ] }, - "invitedAt": "", + "invitedAt": "2026-05-07T02:25:00.000Z", "acceptedAt": "2026-07-02T09:00:00.000Z", "revokedAt": null, "revokedBy": null, "expiresAt": null, "lastUsedAt": null, - "createdAt": "" + "createdAt": "2026-05-07T02:31:00.000Z" } ], "User": { @@ -1843,7 +2261,7 @@ "oidcIssuer": "rt-User-oidcIssuer", "oidcSub": "rt-User-oidcSub", "createdAt": "2026-06-30T12:34:56.789Z", - "updatedAt": "", + "updatedAt": "2026-05-06T23:08:00.000Z", "managedProfileAt": "2026-06-30T12:34:56.789Z", "heightCm": 2.5, "dateOfBirth": "2026-06-30T12:34:56.789Z", @@ -1851,20 +2269,20 @@ "timezone": "America/Chicago", "homeTimezone": "Asia/Tokyo", "homeSince": "2026-06-30T12:34:56.789Z", - "homeLocationEncrypted": "", + "homeLocationEncrypted": "", "environmentAirQualityEnabled": false, "environmentAqHistoryJson": { "roundTrip": "rt-User-environmentAqHistoryJson" }, - "codexAccessTokenEncrypted": "", - "codexRefreshTokenEncrypted": "", + "codexAccessTokenEncrypted": "", + "codexRefreshTokenEncrypted": "", "codexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "codexConnectedAt": "2026-06-30T12:34:56.789Z", "codexConnectionStatus": "rt-User-codexConnectionStatus", "useCentralCodex": true, "insightsPrivacyMode": "raw", "insightsCachedAt": "2026-06-30T12:34:56.789Z", - "insightsCachedTextEncrypted": "", + "insightsCachedTextEncrypted": "", "insightsCachedLocale": "rt-User-insightsCachedLocale", "insightsSnapshotHash": "rt-User-insightsSnapshotHash", "insightsWarmFailedAt": "2026-06-30T12:34:56.789Z", @@ -1879,29 +2297,29 @@ "telegramBotToken": "rt-User-telegramBotToken", "telegramChatId": "rt-User-telegramChatId", "telegramEnabled": true, - "withingsClientIdEncrypted": "", - "withingsClientSecretEncrypted": "", - "whoopClientIdEncrypted": "", - "whoopClientSecretEncrypted": "", - "fitbitClientIdEncrypted": "", - "fitbitClientSecretEncrypted": "", - "googleHealthClientIdEncrypted": "", - "googleHealthClientSecretEncrypted": "", - "nightscoutUrlEncrypted": "", - "nightscoutTokenEncrypted": "", + "withingsClientIdEncrypted": "", + "withingsClientSecretEncrypted": "", + "whoopClientIdEncrypted": "", + "whoopClientSecretEncrypted": "", + "fitbitClientIdEncrypted": "", + "fitbitClientSecretEncrypted": "", + "googleHealthClientIdEncrypted": "", + "googleHealthClientSecretEncrypted": "", + "nightscoutUrlEncrypted": "", + "nightscoutTokenEncrypted": "", "nightscoutAllowPrivateHost": true, - "dawarichUrlEncrypted": "", - "dawarichApiKeyEncrypted": "", - "dawarichPrefsEncrypted": "", + "dawarichUrlEncrypted": "", + "dawarichApiKeyEncrypted": "", + "dawarichPrefsEncrypted": "", "dawarichAllowPrivateHost": true, - "polarAccessTokenEncrypted": "", - "polarUserIdEncrypted": "", - "polarClientIdEncrypted": "", - "polarClientSecretEncrypted": "", - "ouraAccessTokenEncrypted": "", - "ouraRefreshTokenEncrypted": "", - "ouraClientIdEncrypted": "", - "ouraClientSecretEncrypted": "", + "polarAccessTokenEncrypted": "", + "polarUserIdEncrypted": "", + "polarClientIdEncrypted": "", + "polarClientSecretEncrypted": "", + "ouraAccessTokenEncrypted": "", + "ouraRefreshTokenEncrypted": "", + "ouraClientIdEncrypted": "", + "ouraClientSecretEncrypted": "", "ouraScopeDenied": { "roundTrip": "rt-User-ouraScopeDenied" }, @@ -1913,10 +2331,10 @@ "clientAppSharedBy": { "roundTrip": "rt-User-clientAppSharedBy" }, - "stravaClientIdEncrypted": "", - "stravaClientSecretEncrypted": "", - "stravaAccessTokenEncrypted": "", - "stravaRefreshTokenEncrypted": "", + "stravaClientIdEncrypted": "", + "stravaClientSecretEncrypted": "", + "stravaAccessTokenEncrypted": "", + "stravaRefreshTokenEncrypted": "", "stravaAthleteId": "rt-User-stravaAthleteId", "stravaLastActivityAt": "2026-06-30T12:34:56.789Z", "stravaBackfillCompletedAt": "2026-06-30T12:34:56.789Z", @@ -1963,11 +2381,11 @@ "aiProvider": "LOCAL", "aiModel": "rt-User-aiModel", "aiBaseUrl": "https://llm.example.com/v1", - "aiAnthropicKeyEncrypted": "", - "aiLocalKeyEncrypted": "", - "aiOpenaiKeyEncrypted": "", + "aiAnthropicKeyEncrypted": "", + "aiLocalKeyEncrypted": "", + "aiOpenaiKeyEncrypted": "", "aiCompatBaseUrl": "https://gateway.example.com/v1", - "aiCompatKeyEncrypted": "", + "aiCompatKeyEncrypted": "", "aiCompatModel": "rt-User-aiCompatModel", "aiProviderChain": { "roundTrip": "rt-User-aiProviderChain" @@ -2017,14 +2435,14 @@ "avatarBytes": "", "avatarContentType": "image/png", "avatarUpdatedAt": "2026-06-30T12:34:56.789Z", - "totpSecretEncrypted": "", + "totpSecretEncrypted": "", "totpConfirmedAt": "2026-06-30T12:34:56.789Z", "totpLastStep": "bigint:5", "mfaEnforced": true, "passkeyUpgradeNudgeDismissed": true, "fullName": "rt-User-fullName", "insurerName": "rt-User-insurerName", - "insuranceNumberEncrypted": "", + "insuranceNumberEncrypted": "", "insurerIkNumber": "rt-User-insurerIkNumber" }, "AppSettings": { @@ -2036,7 +2454,7 @@ "ntfyGlobal": false, "webPushGlobal": false, "webPushVapidPublicKey": "rt-AppSettings-webPushVapidPublicKey", - "webPushVapidPrivateKeyEncrypted": "", + "webPushVapidPrivateKeyEncrypted": "", "webPushVapidSubject": "rt-AppSettings-webPushVapidSubject", "apiGlobal": false, "umamiEnabled": true, @@ -2050,12 +2468,12 @@ "glitchtipLastFailureReason": "rt-AppSettings-glitchtipLastFailureReason", "reminderLateMinutes": 3, "reminderMissedMinutes": 3, - "adminAiKeyEncrypted": "", + "adminAiKeyEncrypted": "", "adminAiModel": "rt-AppSettings-adminAiModel", "adminAiBaseUrl": "rt-AppSettings-adminAiBaseUrl", - "adminCodexAccessTokenEncrypted": "", - "adminCodexRefreshTokenEncrypted": "", - "adminCodexAccountIdEncrypted": "", + "adminCodexAccessTokenEncrypted": "", + "adminCodexRefreshTokenEncrypted": "", + "adminCodexAccountIdEncrypted": "", "adminCodexTokenExpiresAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectedAt": "2026-06-30T12:34:56.789Z", "adminCodexConnectionStatus": "rt-AppSettings-adminCodexConnectionStatus", diff --git a/tests/integration/backup-dr-stamps-and-ciphertext.test.ts b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts new file mode 100644 index 000000000..512f70636 --- /dev/null +++ b/tests/integration/backup-dr-stamps-and-ciphertext.test.ts @@ -0,0 +1,316 @@ +/** + * A disaster-recovery restore gives back the timestamps and the ciphertext the + * account had, and still takes a recovery file written before it did. + * + * - The row stamps of the medication rows (the drug, its courses, + * composition, category and category label), of the mood day context and + * tag links, of the illness and cycle symptom links and of the nutrient + * days used to come back as the restore's own time. The file now carries + * them and the restore writes them back. + * - A custom category's label, a course note and a marker's context rode a + * recovery file as readable text and were sealed again under whatever key + * was active; the file now carries the ciphertext and the restore writes + * the bytes back unchanged. + * - A recovery file without the new fields (any written before) restores + * the way it always did: the readable values sealed here, the stamps the + * restore's own. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { + decryptContextFromBytes, + encryptContextToBytes, +} from "@/lib/labs/biomarker-store"; + +import { + createOwner, + OWNER_ID, + seedAdminSession, + seedEveryTwoEndedModel, +} from "./backup-fixture"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { POST } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const CREATED = new Date("2026-03-03T03:03:03.000Z"); +const UPDATED = new Date("2026-04-04T04:04:04.000Z"); +const CONTEXT = "fasted, morning draw"; + +beforeEach(async () => { + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); +}); + +/** Give every tracked row a historical stamp, and the marker a context. */ +async function seed(): Promise { + await seedAdminSession(prisma); + await createOwner(prisma); + await seedEveryTwoEndedModel(prisma); + const owner = { userId: OWNER_ID }; + const both = { createdAt: CREATED, updatedAt: UPDATED }; + await prisma.biomarker.updateMany({ + where: owner, + data: { contextEncrypted: encryptContextToBytes(CONTEXT), ...both }, + }); + await prisma.medicationCategoryLabel.updateMany({ where: owner, data: both }); + await prisma.medicationCourse.updateMany({ where: owner, data: both }); + await prisma.medicationIngredient.updateMany({ where: owner, data: both }); + await prisma.medicationCategoryAssignment.updateMany({ + where: { medication: owner }, + data: { updatedAt: UPDATED }, + }); + await prisma.moodContext.updateMany({ where: owner, data: both }); + await prisma.moodEntryTagLink.updateMany({ + where: { moodEntry: owner }, + data: { createdAt: CREATED }, + }); + await prisma.illnessSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.cycleSymptomLink.updateMany({ + where: { dayLog: owner }, + data: { createdAt: CREATED }, + }); + await prisma.nutrientIntakeDay.updateMany({ where: owner, data: both }); + // Last: the writes above do not touch the drug, this pins its own stamp. + await prisma.medication.updateMany({ + where: owner, + data: { updatedAt: UPDATED }, + }); +} + +const b64 = (bytes: Uint8Array | null) => + bytes ? Buffer.from(bytes).toString("base64") : null; + +/** The tracked stamps and ciphertext of the account, in a stable order. */ +async function tracked(client: PrismaClient) { + const owner = { userId: OWNER_ID }; + return { + medication: await client.medication.findMany({ + where: owner, + orderBy: { name: "asc" }, + select: { updatedAt: true }, + }), + assignment: await client.medicationCategoryAssignment.findMany({ + where: { medication: owner }, + select: { updatedAt: true }, + }), + labels: ( + await client.medicationCategoryLabel.findMany({ + where: owner, + select: { labelEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, labelEncrypted: b64(r.labelEncrypted) })), + courses: ( + await client.medicationCourse.findMany({ + where: owner, + orderBy: { startsOn: "asc" }, + select: { noteEncrypted: true, createdAt: true, updatedAt: true }, + }) + ).map((r) => ({ ...r, noteEncrypted: b64(r.noteEncrypted) })), + ingredients: await client.medicationIngredient.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodContexts: await client.moodContext.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + moodLinks: await client.moodEntryTagLink.findMany({ + where: { moodEntry: owner }, + select: { createdAt: true }, + }), + illnessLinks: await client.illnessSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + cycleLinks: await client.cycleSymptomLink.findMany({ + where: { dayLog: owner }, + select: { createdAt: true }, + }), + nutrients: await client.nutrientIntakeDay.findMany({ + where: owner, + select: { createdAt: true, updatedAt: true }, + }), + markers: ( + await client.biomarker.findMany({ + where: owner, + select: { contextEncrypted: true }, + }) + ).map((r) => ({ contextEncrypted: b64(r.contextEncrypted) })), + }; +} + +async function restore(payload: unknown): Promise { + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "DR_STAMPS", + data: encrypt(JSON.stringify(payload)), + }, + }); + const startedAt = new Date(); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + return startedAt; +} + +type Payload = Record & { + customMedicationCategories: Array>; + medications: Array>; + moodEntries: Array>; + illnessEpisodes: Array>; + cycleDayLogs: Array>; + nutrientDays: Array>; + biomarkers: Array>; +}; + +/** The same file as a release before this one wrote it. */ +function asOlderRecoveryFile(payload: Payload): Payload { + const older = structuredClone(payload); + for (const c of older.customMedicationCategories) { + c.label = decryptFromBytes( + Buffer.from(c.labelEncrypted as string, "base64"), + ); + delete c.labelEncrypted; + delete c.updatedAt; + } + for (const m of older.medications) { + delete m.categoryUpdatedAt; + for (const i of m.ingredients as Array>) { + delete i.updatedAt; + } + for (const c of m.courses as Array>) { + c.note = c.noteEncrypted + ? decryptFromBytes(Buffer.from(c.noteEncrypted as string, "base64")) + : null; + delete c.noteEncrypted; + delete c.updatedAt; + } + } + for (const e of older.moodEntries) { + delete e.linkCreatedAt; + const context = e.context as Record | null; + if (context) { + delete context.createdAt; + delete context.updatedAt; + } + } + for (const episode of older.illnessEpisodes) { + for (const day of episode.dayLogs as Array>) { + for (const s of day.symptoms as Array>) { + delete s.createdAt; + } + } + } + for (const day of older.cycleDayLogs) delete day.symptomLinkCreatedAt; + for (const n of older.nutrientDays) delete n.updatedAt; + for (const marker of older.biomarkers) { + marker.context = marker.contextEncrypted + ? decryptContextFromBytes( + Buffer.from(marker.contextEncrypted as string, "base64"), + ) + : null; + delete marker.contextEncrypted; + } + return older; +} + +describe("a disaster-recovery restore", () => { + it("gives back the row stamps and the ciphertext as they were", async () => { + await seed(); + const before = await tracked(prisma); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const file = JSON.stringify(payload); + // None of the three sealed values rides the file readable. + expect(file).not.toContain("Heart and circulation"); + expect(file).not.toContain("the March flu"); + expect(file).not.toContain(CONTEXT); + + await restore(payload); + const after = await tracked(prisma); + expect(after).toEqual(before); + // The comparison saw rows, not two empty lists. + for (const rows of Object.values(after)) + expect(rows.length).toBeGreaterThan(0); + }); + + it("takes an older recovery file the way it always did", async () => { + await seed(); + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose: "disaster-recovery", + }); + const startedAt = await restore(asOlderRecoveryFile(payload as Payload)); + + const label = await prisma.medicationCategoryLabel.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptFromBytes(label.labelEncrypted)).toBe( + "Heart and circulation", + ); + const course = await prisma.medicationCourse.findFirstOrThrow({ + where: { userId: OWNER_ID, noteEncrypted: { not: null } }, + }); + expect(decryptFromBytes(course.noteEncrypted!)).toBe("the March flu"); + const marker = await prisma.biomarker.findFirstOrThrow({ + where: { userId: OWNER_ID }, + }); + expect(decryptContextFromBytes(marker.contextEncrypted!)).toBe(CONTEXT); + + const after = await tracked(prisma); + const restamped = [ + ...after.labels.map((r) => r.updatedAt), + ...after.courses.map((r) => r.updatedAt), + ...after.ingredients.map((r) => r.updatedAt), + ...after.assignment.map((r) => r.updatedAt), + ...after.moodContexts.map((r) => r.createdAt), + ...after.moodLinks.map((r) => r.createdAt), + ...after.illnessLinks.map((r) => r.createdAt), + ...after.cycleLinks.map((r) => r.createdAt), + ...after.nutrients.map((r) => r.updatedAt), + ]; + expect(restamped.length).toBeGreaterThan(8); + for (const at of restamped) { + expect(at.getTime()).toBeGreaterThanOrEqual(startedAt.getTime() - 1000); + } + }); +}); diff --git a/tests/integration/backup-fixture.ts b/tests/integration/backup-fixture.ts index 1ce127aee..bfe84a6c5 100644 --- a/tests/integration/backup-fixture.ts +++ b/tests/integration/backup-fixture.ts @@ -1374,17 +1374,6 @@ export async function seedAdminSession(prisma: PrismaClient) { export const COLUMN_EXCLUSIONS: Readonly> = { "MeasurementReminder.lastNotifiedAt": "operational repeat cursor, documented on the column as not in the backup: a restored reminder re-reminds at most once, on its next notify hour", - // When a join row was written. Nothing reads these: the links are read for - // which tag, symptom or context applies, never for when the row appeared, - // and the parent entry carries its own timestamps, which do travel. - "MoodContext.createdAt": - "insert stamp of the per-entry context row; no reader, the mood entry's own timestamps travel", - "MoodEntryTagLink.createdAt": - "insert stamp of a tag link; no reader, the mood entry's own timestamps travel", - "CycleSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the day log's own timestamps travel", - "IllnessSymptomLink.createdAt": - "insert stamp of a symptom link; no reader, the illness day log's own timestamps travel", }; /** diff --git a/tests/integration/backup-parity.test.ts b/tests/integration/backup-parity.test.ts index 0d6903442..3e83e614c 100644 --- a/tests/integration/backup-parity.test.ts +++ b/tests/integration/backup-parity.test.ts @@ -22,15 +22,29 @@ * 5. Hand the restore malformed and unresolvable files and pin each refusal * and each skip report, and the schema's issue list for a bad file. * + * The account carries a tombstone of every model that keeps them and both + * self-references (a flare under its condition, a superseded profile fact), + * so the second restore pass has something to resolve. Every timestamp the + * database stamped while the fixture ran is pinned to a fixed historical + * value, and the seeded account is asserted to hold none of this run's: a + * restore that drops a row's `createdAt` then shows up as `` against a + * historical value instead of hiding behind a seeded ``. The fixture is + * sealed under the legacy key `v1`, and a second key is active from the + * export on, so a value the restore re-encrypts instead of carrying over + * comes back under `v2`. + * * Values that legitimately differ between two runs are normalised, never * dropped: ids become `` in order of first appearance, ciphertext - * becomes the text it opens to (or its length when no codec opens it), and a - * timestamp written during the run becomes ``. Key order is kept, so a - * file that carries the same values in another order differs. + * becomes the key id it names and the text it opens to (or its length when no + * codec opens it), and a timestamp written during the run becomes ``. + * Key order is kept, so a file that carries the same values in another order + * differs. * * `BACKUP_PARITY_WRITE=1` rewrites the golden copies. Only do that for a * change that means to alter the backup, and review the diff of - * `tests/integration/__golden__/backup-parity/` as part of it. + * `tests/integration/__golden__/backup-parity/` as part of it. A change to + * this harness itself writes them from the backup code as it was before the + * change under test, never from the code being checked. */ import { Buffer } from "node:buffer"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -39,8 +53,16 @@ import { join, resolve } from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { PrismaClient } from "@/generated/prisma/client"; -import { decrypt, decryptBytes, encrypt } from "@/lib/crypto"; -import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { + _resetCryptoCacheForTests, + decrypt, + decryptBytes, + encrypt, + encryptBytes, + extractKeyId, + extractKeyIdFromBytes, +} from "@/lib/crypto"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { openDayCountry } from "@/lib/dawarich/connection"; import { openLocation } from "@/lib/environment/location-cipher"; import { decryptFactData, decryptFactProvenance } from "@/lib/documents/store"; @@ -68,6 +90,7 @@ import { syntheticValue, } from "./backup-column-audit"; import { + AT, COLUMN_PRESENCE_ONLY, FILL_OVERRIDES, OWNER_ID, @@ -187,6 +210,34 @@ function openBytes(value: Uint8Array): string | null { return null; } +/** + * The key id of a sealed byte value: the header of the binary codec, or the + * `.` prefix of a string-codec ciphertext stored as its UTF-8 bytes. + * `-` for neither. + */ +function bytesKeyId(value: Uint8Array): string { + const buffer = Buffer.from(value); + return ( + extractKeyIdFromBytes(buffer) ?? + extractKeyId(buffer.toString("utf8")) ?? + "-" + ); +} + +/** + * The key id of a sealed string: the `.` prefix of the string codec, or + * the header of a sealed byte value carried as base64. `-` for neither (the + * unversioned legacy string form, or another codec). + */ +function stringKeyId(value: string): string { + const id = extractKeyId(value); + if (id !== null) return id; + if (value.length % 4 === 0 && BASE64.test(value)) { + return bytesKeyId(Buffer.from(value, "base64")); + } + return "-"; +} + function openString(value: string): string | null { try { return decrypt(value); @@ -234,11 +285,18 @@ class Normaliser { return DAY.test(out) && this.days.has(out) ? "" : out; } + /** + * A sealed string, as the key it was sealed under and the text it opens to. + * The key id is part of the comparison: a restore that re-encrypted a value + * the file carried as ciphertext opens to the same text under another key, + * and only the id shows it. + */ sealed(value: string): string { const opened = openString(value); + const kid = stringKeyId(value); return opened === null - ? `` - : ``; + ? `` + : ``; } /** A JSON value, key order kept. */ @@ -267,7 +325,7 @@ class Normaliser { const opened = openBytes(value); return opened === null ? `` - : ``; + : ``; } if (typeof value === "bigint") return `bigint:${value}`; if ( @@ -515,7 +573,369 @@ async function seedMaximalAccount(prisma: PrismaClient) { acceptedAt: new Date("2026-07-02T09:00:00.000Z"), }, }); - return { admin, unfillable: unfillable.sort() }; + await seedTombstones(prisma); + await seedSelfReferences(prisma); + const unpinned = await pinRunTimeTimestamps(prisma); + rotateActiveKey(); + return { admin, unfillable: unfillable.sort(), unpinned }; +} + +/** + * One soft-deleted row of every model that keeps tombstones, beside the live + * rows the fixture seeds. A disaster-recovery file carries the tombstones a + * device sync needs to keep a deleted row deleted, and the golden copies pin + * which models' tombstones the file carries and that each comes back deleted. + * Seeded after the column fill so the fill still lands on the live rows. + */ +async function seedTombstones(prisma: PrismaClient) { + const deletedAt = AT(TOMBSTONE_DELETED_AT); + const userId = OWNER_ID; + const medication = await prisma.medication.findFirstOrThrow({ + where: { userId, name: "Round-trip tablet" }, + }); + const episode = await prisma.illnessEpisode.findFirstOrThrow({ + where: { userId, label: "Cold" }, + }); + const cycle = await prisma.menstrualCycle.findFirstOrThrow({ + where: { userId, startDate: "2026-06-01" }, + }); + const metric = await prisma.customMetric.findFirstOrThrow({ + where: { userId, name: "Grip strength" }, + }); + const thread = await prisma.coachConversation.findFirstOrThrow({ + where: { userId, documentScoped: false }, + }); + + await prisma.medicationIntakeEvent.create({ + data: { + userId, + medicationId: medication.id, + scheduledFor: AT("2026-07-02T08:00:00.000Z"), + takenAt: AT("2026-07-02T08:03:00.000Z"), + deletedAt, + }, + }); + await prisma.mentalHealthAssessment.create({ + data: { + userId, + instrument: "GAD7", + locale: "en", + version: "standard", + responsesEncrypted: encryptToBytes( + JSON.stringify({ items: [1, 1, 0, 1, 0, 1, 0], schema: 1 }), + ), + totalScore: 4, + severityBand: "minimal", + takenAt: AT("2026-06-05T18:00:00.000Z"), + tz: "Europe/Berlin", + source: "WEB", + deletedAt, + }, + }); + await prisma.moodEntry.create({ + data: { + userId, + date: "2026-07-03", + mood: "SCHLECHT", + score: 2, + moodLoggedAt: AT("2026-07-03T20:00:00.000Z"), + deletedAt, + }, + }); + await prisma.measurementReminder.create({ + data: { + userId, + label: "Gewicht (deleted)", + measurementType: "WEIGHT", + intervalDays: 14, + deletedAt, + }, + }); + await prisma.labResult.create({ + data: { + userId, + analyte: "TSH", + value: 2.1, + unit: "mU/L", + takenAt: AT("2026-05-30T09:00:00.000Z"), + deletedAt, + }, + }); + await prisma.customMetric.create({ + data: { userId, name: "Plank (deleted)", unit: "s", deletedAt }, + }); + await prisma.customMetricEntry.create({ + data: { + userId, + customMetricId: metric.id, + value: 40, + unit: "kg", + measuredAt: AT("2026-06-28T18:00:00.000Z"), + deletedAt, + }, + }); + await prisma.coachFact.create({ + data: { + userId, + factEncrypted: encryptToBytes("a fact the person deleted"), + category: "preference", + sourceConversationId: thread.id, + source: "user", + deletedAt, + }, + }); + const plan = await prisma.coachPlan.create({ + data: { + userId, + metric: "WEIGHT", + ifCueEncrypted: encryptToBytes("if the scale reads over 75"), + thenActionEncrypted: encryptToBytes("then skip the second helping"), + status: "active", + deletedAt, + }, + }); + await prisma.coachReminder.create({ + data: { + userId, + noteEncrypted: encryptToBytes("a reminder the person deleted"), + relatedPlanId: plan.id, + triggerKind: "date", + dueAt: AT("2026-09-15T07:00:00.000Z"), + status: "active", + source: "extractor", + deletedAt, + }, + }); + await prisma.menstrualCycle.create({ + data: { userId, startDate: "2026-05-04", deletedAt }, + }); + await prisma.cycleDayLog.create({ + data: { + userId, + cycleId: cycle.id, + date: "2026-06-03", + flow: "LIGHT", + deletedAt, + }, + }); + await prisma.illnessEpisode.create({ + data: { + userId, + label: "Sprained ankle (deleted)", + type: "INJURY", + onsetAt: AT("2026-04-10T00:00:00.000Z"), + deletedAt, + }, + }); + await prisma.illnessDayLog.create({ + data: { userId, episodeId: episode.id, date: "2026-06-22", deletedAt }, + }); + await prisma.allergy.create({ + data: { userId, substance: "Latex (deleted)", deletedAt }, + }); + await prisma.familyHistoryEntry.create({ + data: { + userId, + relationship: "FATHER", + condition: "Hypertension (deleted)", + notesEncrypted: encryptToBytes("diagnosed at fifty"), + deletedAt, + }, + }); + const bytes = encryptBytes(Buffer.from("a page the person deleted")); + const contentEncrypted = new Uint8Array(new ArrayBuffer(bytes.byteLength)); + contentEncrypted.set(bytes); + await prisma.inboundDocument.create({ + data: { + userId, + kind: "OTHER", + title: "Deleted scan", + filename: "deleted-scan.pdf", + mimeType: "application/pdf", + byteSize: bytes.byteLength, + contentEncrypted, + contentCodec: "binary2", + deletedAt, + }, + }); + const practitioner = await prisma.practitioner.create({ + data: { userId, name: "Closed practice (deleted)", deletedAt }, + }); + await prisma.encounter.create({ + data: { + userId, + occurredAt: AT("2026-04-02T08:00:00.000Z"), + status: "DONE", + kind: "ROUTINE", + practitionerId: practitioner.id, + deletedAt, + }, + }); + await prisma.customVaccine.create({ + data: { userId, name: "Deleted combo", components: ["rabies"], deletedAt }, + }); + await prisma.vaccinationRecord.create({ + data: { + userId, + occurredAt: AT("2026-04-14T00:00:00.000Z"), + antigenSlug: "influenza", + vaccineName: "Influenza (deleted)", + deletedAt, + }, + }); + await prisma.lifeEvent.create({ + data: { + userId, + category: "OTHER", + startDate: "2025-12-01", + precision: "DAY", + titleEncrypted: encryptToBytes("an event the person deleted"), + deletedAt, + }, + }); +} + +/** + * The two models that point at another row of themselves. The restore writes + * those pointers in a second pass once every row of the model is back; with + * the pointers null, nothing in the file exercised it. + */ +async function seedSelfReferences(prisma: PrismaClient) { + const userId = OWNER_ID; + const chronic = await prisma.illnessEpisode.findFirstOrThrow({ + where: { userId, label: "Iron deficiency" }, + }); + await prisma.illnessEpisode.create({ + data: { + userId, + label: "Iron deficiency flare", + type: "CHRONIC", + lifecycle: "FLARE", + onsetAt: AT("2026-06-25T00:00:00.000Z"), + parentConditionId: chronic.id, + }, + }); + const first = await prisma.healthProfileFactRevision.findFirstOrThrow({ + where: { userId, kind: "SMOKING_STATUS" }, + }); + const correction = await prisma.healthProfileFactRevision.create({ + data: { + userId, + kind: "SMOKING_STATUS", + valueEncrypted: encryptToBytes("former"), + validFrom: AT("2026-02-01T00:00:00.000Z"), + provenance: "USER_REPORTED", + }, + }); + await prisma.healthProfileFactRevision.update({ + where: { id: first.id }, + data: { supersededByRevisionId: correction.id }, + }); +} + +/** + * Give every timestamp the database stamped while the fixture ran a fixed + * historical value. A restore that loses a row's `createdAt` writes the + * restore's own time; while the seeded value was itself "now", both read as + * `` after normalisation and the loss could not show. Pinned, the seeded + * account holds no run-time timestamp at all (asserted below), so a `` in + * a restored row is the restore's own doing and is pinned as such. Returns the + * columns that would not take a value. + */ +async function pinRunTimeTimestamps(prisma: PrismaClient): Promise { + const from = RUN_STARTED - 5 * 60_000; + const runTime = (value: unknown) => + value instanceof Date && value.getTime() >= from; + const base = Date.UTC(2026, 4, 1, 8, 0, 0); + const unpinned: string[] = []; + const pin = async ( + model: AuditModel, + rows: Record[], + modelIndex: number, + ) => { + for (const [rowIndex, row] of rows.entries()) { + const data: Record = {}; + model.columns.forEach((column, columnIndex) => { + if (column.type !== "DateTime" || !runTime(row[column.name])) return; + data[column.name] = new Date( + base + (modelIndex * 100 + columnIndex) * 60_000 + rowIndex * 1000, + ); + }); + if (Object.keys(data).length === 0) continue; + try { + await delegateOf(prisma, model).update({ + where: primaryKeyWhere(model, row), + data, + }); + } catch { + unpinned.push(`${model.name}:${Object.keys(data).join(",")}`); + } + } + }; + const models = auditModels(prisma); + const byName = new Map(models.map((m) => [m.name, m])); + for (const [index, model] of models.entries()) { + const rows = await delegateOf(prisma, model).findMany({ + where: ownerWhere(byName, prisma, model.name, OWNER_ID), + orderBy: model.primaryKey.map((key) => ({ [key]: "asc" as const })), + }); + await pin(model, rows, index); + } + const extra = readAuditModels(prisma, [ + "User", + "AppSettings", + "AccountGrant", + ]); + const [user, settings, grant] = extra; + await pin( + user, + [await prisma.user.findUniqueOrThrow({ where: { id: OWNER_ID } })], + models.length, + ); + await pin( + settings, + [ + await prisma.appSettings.findUniqueOrThrow({ + where: { id: "singleton" }, + }), + ], + models.length + 1, + ); + await pin( + grant, + (await prisma.accountGrant.findMany({ + where: { grantorId: OWNER_ID }, + orderBy: { id: "asc" }, + })) as Record[], + models.length + 2, + ); + return unpinned.sort(); +} + +/** + * Everything above was sealed under the legacy key, which the environment + * names `v1`. From here on a second key is the active one, so a value the + * restore re-encrypts instead of carrying over comes back under `v2`, and the + * key id in the normalised copies shows it. + */ +const ORIGINAL_KEY_ENV = { + keys: process.env.ENCRYPTION_KEYS, + active: process.env.ENCRYPTION_ACTIVE_KEY_ID, +}; +function rotateActiveKey() { + process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "11".repeat(32) }); + process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2"; + _resetCryptoCacheForTests(); +} +function restoreKeyEnv() { + for (const [name, value] of [ + ["ENCRYPTION_KEYS", ORIGINAL_KEY_ENV.keys], + ["ENCRYPTION_ACTIVE_KEY_ID", ORIGINAL_KEY_ENV.active], + ] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + _resetCryptoCacheForTests(); } /** Every row the account holds, model by model, normalised. */ @@ -705,15 +1125,20 @@ describe("backup parity", () => { }, SLOW); afterAll(async () => { + restoreKeyEnv(); await truncateAllTables(prisma); }, SLOW); it( "exports the maximal account as it did", async () => { - const { admin, unfillable } = await seedMaximalAccount(prisma); + const { admin, unfillable, unpinned } = await seedMaximalAccount(prisma); adminId = admin.id; const before = await snapshotAccount(prisma, OWNER_ID); + // The seeded account holds no timestamp of this run, so every `` + // further down is one a restore or an export wrote. + expect(unpinned).toEqual([]); + expect(JSON.stringify(before)).not.toContain(""); const dr = await exportFile(prisma, "disaster-recovery"); const portable = await exportFile(prisma, "portable-export"); drJson = dr.json; diff --git a/tests/integration/backup-random-account.property.test.ts b/tests/integration/backup-random-account.property.test.ts index f88553000..33f7c3040 100644 --- a/tests/integration/backup-random-account.property.test.ts +++ b/tests/integration/backup-random-account.property.test.ts @@ -21,7 +21,7 @@ import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import type { Prisma } from "@/generated/prisma/client"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; -import { assertAsyncProperty } from "../property/fast-check"; +import { assertAsyncProperty, propertyTimeout } from "../property/fast-check"; import { callRoute } from "../kit/call-route"; import { makeAdmin, makeSession, makeUser } from "../kit/factories"; import { POST as restoreBackup } from "./restore-job-driver"; @@ -264,56 +264,62 @@ beforeEach(async () => { }); describe("a generated account", () => { - it("comes back from a disaster-recovery backup exactly as it left", async () => { - await assertAsyncProperty( - fc.asyncProperty(accountArb, async (account) => { - await truncateAllTables(prisma); - await seed(account); - const before = await dump(); + it( + "comes back from a disaster-recovery backup exactly as it left", + async () => { + await assertAsyncProperty( + fc.asyncProperty(accountArb, async (account) => { + await truncateAllTables(prisma); + await seed(account); + const before = await dump(); - let json = ""; - await streamFullBackupJson( - prisma, - OWNER_ID, - (chunk) => { - json += chunk; - }, - { - purpose: "disaster-recovery", - exportedAt: new Date("2026-10-01T00:00:00.000Z"), - }, - ); + let json = ""; + await streamFullBackupJson( + prisma, + OWNER_ID, + (chunk) => { + json += chunk; + }, + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-10-01T00:00:00.000Z"), + }, + ); - await prisma.user.delete({ where: { id: OWNER_ID } }); - await makeUser({ id: OWNER_ID }); - const backup = await prisma.dataBackup.create({ - data: { - userId: OWNER_ID, - type: "RANDOM_ACCOUNT_ROUND_TRIP", - data: await legacyStreamedBlobFrom(async (write) => { - await write(json); - }), - }, - }); - const operator = await makeSession(await makeAdmin()); - const restored = await callRoute(restoreBackup, { - method: "POST", - route: "/api/admin/backups/{id}/restore", - params: { id: backup.id }, - body: { confirm: "RESTORE" }, - auth: operator, - contract: { - skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", - }, - }); - expect( - restored.status, - JSON.stringify(restored.body).slice(0, 300), - ).toBe(200); + await prisma.user.delete({ where: { id: OWNER_ID } }); + await makeUser({ id: OWNER_ID }); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "RANDOM_ACCOUNT_ROUND_TRIP", + data: await legacyStreamedBlobFrom(async (write) => { + await write(json); + }), + }, + }); + const operator = await makeSession(await makeAdmin()); + const restored = await callRoute(restoreBackup, { + method: "POST", + route: "/api/admin/backups/{id}/restore", + params: { id: backup.id }, + body: { confirm: "RESTORE" }, + auth: operator, + contract: { + skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", + }, + }); + expect( + restored.status, + JSON.stringify(restored.body).slice(0, 300), + ).toBe(200); - expect(await dump()).toEqual(before); - }), - 12, - ); - }); + expect(await dump()).toEqual(before); + }), + 12, + ); + // Twelve round trips take a few seconds; the nightly factor multiplies + // both the cases and this budget. + }, + propertyTimeout(60_000), + ); }); diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index fcd577d42..5b7c100da 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1924,14 +1924,26 @@ describe("every model the plan claims two-ended survives a real restore", () => Unfiled: "OTHER", Tamiflu: ROUND_TRIP_CATEGORY_KEY, }); + // A portable file carries the label readable; a disaster-recovery file + // carries its ciphertext, like every other sealed column. expect(payload.customMedicationCategories).toEqual([ - { - key: ROUND_TRIP_CATEGORY_KEY, - label: "Travel kit", - sortOrder: 0, - isActive: false, - createdAt: expect.any(String), - }, + purpose === "disaster-recovery" + ? { + key: ROUND_TRIP_CATEGORY_KEY, + label: null, + labelEncrypted: expect.any(String), + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + updatedAt: expect.any(String), + } + : { + key: ROUND_TRIP_CATEGORY_KEY, + label: "Travel kit", + sortOrder: 0, + isActive: false, + createdAt: expect.any(String), + }, ]); await prisma.user.delete({ where: { id: OWNER_ID } }); diff --git a/tests/integration/bulk-writer-cache-invalidation.test.ts b/tests/integration/bulk-writer-cache-invalidation.test.ts new file mode 100644 index 000000000..81ce584cc --- /dev/null +++ b/tests/integration/bulk-writer-cache-invalidation.test.ts @@ -0,0 +1,176 @@ +/** + * Every bulk writer of measurements marks the cached reads stale back to the + * oldest reading it wrote. + * + * A closed analytics block (a value over readings before a fixed instant) + * survives every write that says it reached no further back, so a writer that + * did not invalidate at all left the block on its pre-write value: an Apple + * Health export carrying years of history showed the old all-time figures + * until the entry aged out, and the next sync, reaching back only a day, + * spared it. Pinned here for the writers that had no invalidation: the Apple + * Health import worker, the shared post-mutation tail the CSV and JSON + * imports and the score engines rely on, the Oura upsert a history backfill + * writes through, and the sleep-segment sweep. + */ +import { createHash } from "node:crypto"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { strToU8, zipSync } from "fflate"; +import type { Job } from "pg-boss"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { closedBlockKeyPart } from "@/lib/cache/closed-block"; +import { caches, type ServerCache } from "@/lib/cache/server-cache"; +import { + APPLE_HEALTH_IMPORT_PARSER_REVISION, + handleAppleHealthImport, + _setWorkerPrismaForTests, + type AppleHealthImportPayload, +} from "@/lib/jobs/apple-health-import-worker"; +import { upsertOuraMeasurements } from "@/lib/oura/sync"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; +import { sweepStaleSleepSegments } from "@/lib/sleep/sweep-stale-segments"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const prisma = getPrismaClient(); +const analytics = caches.analytics as ServerCache<{ v: number }>; +const USER = "user-bulk-writer-cache"; +const DAY_MS = 86_400_000; +const NOW = Date.now(); +const yearsAgo = (years: number) => new Date(NOW - years * 365 * DAY_MS); + +/** A closed block over the readings before `before`, and an open entry. */ +function seedCache(before: Date) { + const closed = `${USER}|pre-fold-remainder|${closedBlockKeyPart(before)}|x`; + const open = `${USER}|dashboard-snapshot|en`; + analytics.deleteByPrefix(`${USER}|`); + analytics.set(closed, { v: 1 }); + analytics.set(open, { v: 2 }); + return { closed, open }; +} + +beforeEach(async () => { + await truncateAllTables(prisma); + _setWorkerPrismaForTests(prisma); + await prisma.user.create({ + data: { + id: USER, + username: USER, + email: `${USER}@example.test`, + timezone: "UTC", + }, + }); +}); + +function record(at: Date, value: number): string { + const stamp = at.toISOString().replace("T", " ").slice(0, 19) + " +0000"; + return ``; +} + +async function runImport(xmlRecords: string[]) { + const xml = ` + + ${xmlRecords.join("\n ")} +`; + const dir = mkdtempSync(join(tmpdir(), "hl-import-cache-")); + const path = join(dir, "export.zip"); + const bytes = Buffer.from( + zipSync({ "apple_health_export/export.xml": strToU8(xml) }), + ); + writeFileSync(path, bytes); + await prisma.importJob.create({ + data: { + userId: USER, + pgBossJobId: "cache-job", + status: "queued", + uploadBytes: bytes.length, + uploadSha256: createHash("sha256").update(bytes).digest("hex"), + parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION, + }, + }); + const outcome = await handleAppleHealthImport({ + id: "cache-job", + data: { + userId: USER, + uploadPath: path, + uploadBytes: bytes.length, + enqueuedAt: new Date().toISOString(), + }, + } as unknown as Job); + expect(outcome.ok).toBe(true); +} + +describe("bulk writers invalidate the measurement read caches", () => { + it("the Apple Health import marks stale every block its oldest sample reaches", async () => { + const reached = seedCache(yearsAgo(1)); + const older = `${USER}|pre-fold-remainder|${closedBlockKeyPart(yearsAgo(9))}|x`; + analytics.set(older, { v: 3 }); + + await runImport([record(yearsAgo(7), 88), record(yearsAgo(0.1), 84)]); + + expect(analytics.get(reached.closed)).toBeNull(); + expect(analytics.get(reached.open)).toBeNull(); + // A block of readings older than anything imported stays. + expect(analytics.get(older)).toEqual({ v: 3 }); + }); + + it("the shared post-mutation tail invalidates back to its oldest row", async () => { + const cache = seedCache(yearsAgo(1)); + await afterMeasurementMutation( + USER, + [{ type: "WEIGHT", measuredAt: yearsAgo(3) }], + { source: "manual" }, + ); + expect(analytics.get(cache.closed)).toBeNull(); + expect(analytics.get(cache.open)).toBeNull(); + }); + + it("the Oura upsert a history backfill writes through invalidates", async () => { + const cache = seedCache(yearsAgo(1)); + const written = await upsertOuraMeasurements( + USER, + [ + { + type: "RESTING_HEART_RATE", + value: 52, + unit: "bpm", + measuredAt: yearsAgo(2), + externalId: "oura:test:resting:1", + }, + ], + { emitArrivals: false }, + ); + expect(written).toBe(1); + expect(analytics.get(cache.closed)).toBeNull(); + }); + + it("a sleep-segment sweep that tombstones a row invalidates", async () => { + await prisma.measurement.create({ + data: { + userId: USER, + type: "SLEEP_DURATION", + value: 60, + unit: "min", + source: "OURA", + measuredAt: yearsAgo(2), + externalId: "night-1:stale", + }, + }); + const cache = seedCache(yearsAgo(1)); + const removed = await sweepStaleSleepSegments(USER, "OURA", [ + { prefix: "night-1:", keepIds: ["night-1:fresh"] }, + ]); + expect(removed).toBe(1); + expect(analytics.get(cache.closed)).toBeNull(); + }); +}); diff --git a/tests/integration/coach-labs-analyte-history.test.ts b/tests/integration/coach-labs-analyte-history.test.ts new file mode 100644 index 000000000..8a64acb14 --- /dev/null +++ b/tests/integration/coach-labs-analyte-history.test.ts @@ -0,0 +1,66 @@ +/** + * The Coach's history of one lab marker is read in full, whatever else the + * account has measured since. + * + * The block used to read the newest 4,000 lab rows of every marker and match + * the asked-for one in memory afterwards. An account with more than that many + * newer readings of other markers (a home glucose or INR log is enough) lost + * the asked-for marker's older readings, so its `count` and `firstTakenAt` + * named a shorter history than the record holds, or the marker vanished. The + * same history also left out a reading whose unit was written another way + * ("MG/DL" beside "mg/dL"). + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; + +import { makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(() => truncateAllTables(prisma)); + +describe("buildLabsSnapshotBlock with an analyte", () => { + it("reads the marker's whole history past thousands of newer readings of other markers", async () => { + const user = await makeUser(); + const now = new Date(); + const daysAgo = (days: number) => new Date(now.getTime() - days * DAY_MS); + + // Three LDL readings, the oldest two years back; one of them stored with + // the unit written in capitals. + await prisma.labResult.createMany({ + data: [ + { takenAt: daysAgo(730), value: 151, unit: "MG/DL" }, + { takenAt: daysAgo(400), value: 142, unit: "mg/dL" }, + { takenAt: daysAgo(200), value: 128, unit: "mg/dL" }, + ].map((reading) => ({ + userId: user.id, + analyte: "LDL", + ...reading, + })), + }); + // 4,500 newer readings of another marker, all after the last LDL. + await prisma.labResult.createMany({ + data: Array.from({ length: 4_500 }, (_, index) => ({ + userId: user.id, + analyte: "Glucose", + value: 90 + (index % 20), + unit: "mg/dL", + takenAt: new Date(daysAgo(199).getTime() + index * 60_000), + })), + }); + + const block = await buildLabsSnapshotBlock(user.id, now, undefined, { + analyte: "ldl", + }); + + expect(block?.recent).toHaveLength(1); + const ldl = block!.recent[0]!; + expect(ldl.analyte).toBe("LDL"); + expect(ldl.value).toBe(128); + expect(ldl.history?.count).toBe(3); + expect(ldl.history?.firstTakenAt).toBe(daysAgo(730).toISOString()); + }); +}); diff --git a/tests/integration/family-history-backup-tombstones.test.ts b/tests/integration/family-history-backup-tombstones.test.ts new file mode 100644 index 000000000..e83853055 --- /dev/null +++ b/tests/integration/family-history-backup-tombstones.test.ts @@ -0,0 +1,53 @@ +/** + * The disaster-recovery file carries deleted family-history entries, as it + * carries every other record's tombstones, and carries their note as the + * ciphertext at rest. The portable file carries the live entries only, with + * the note readable. The full round trip, the restored tombstone included, is + * pinned by the backup parity golden copies. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { buildFamilyHistoryBackupSlice } from "@/lib/export/family-history-backup"; + +import { makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +beforeEach(() => truncateAllTables(prisma)); + +describe("family history in the backup", () => { + it("keeps a deleted entry in the disaster-recovery file and out of the portable one", async () => { + const user = await makeUser(); + const notes = encryptToBytes("diagnosed at fifty"); + await prisma.familyHistoryEntry.create({ + data: { userId: user.id, relationship: "MOTHER", condition: "Asthma" }, + }); + const deleted = await prisma.familyHistoryEntry.create({ + data: { + userId: user.id, + relationship: "FATHER", + condition: "Hypertension", + notesEncrypted: notes, + deletedAt: new Date("2026-09-30T10:00:00.000Z"), + }, + }); + + const dr = await buildFamilyHistoryBackupSlice(prisma, user.id, true); + expect(dr.familyHistory).toHaveLength(2); + const tombstone = dr.familyHistory.find((e) => e.id === deleted.id); + expect(tombstone?.deletedAt).toBe("2026-09-30T10:00:00.000Z"); + expect(tombstone?.note).toBeNull(); + expect(tombstone?.notesEncrypted).toBe( + Buffer.from(notes).toString("base64"), + ); + + const portable = await buildFamilyHistoryBackupSlice( + prisma, + user.id, + false, + ); + expect(portable.familyHistory.map((e) => e.condition)).toEqual(["Asthma"]); + }); +}); diff --git a/tests/integration/routes/measurements-batch-unchanged-stats.test.ts b/tests/integration/routes/measurements-batch-unchanged-stats.test.ts index d4a0b6cd2..367c40dd4 100644 --- a/tests/integration/routes/measurements-batch-unchanged-stats.test.ts +++ b/tests/integration/routes/measurements-batch-unchanged-stats.test.ts @@ -9,8 +9,9 @@ * `updatedAt`), and starts nothing: no rollup refold, no cache * invalidation, no PR detection; * - goes the old way as soon as one column differs (the value, the unit, - * the device), and for a tombstoned row (a resurrection), so those are - * written and start the tail as before; + * the device, the source version, the sleep stage), and for a tombstoned + * row (a resurrection), so those are written and start the tail as + * before; * - costs a handful of statements for a batch of them, instead of a * reconciler transaction each. */ @@ -72,8 +73,14 @@ const prisma = getPrismaClient(); const ROUTE = "/api/measurements/batch"; const DAYS = ["2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04"]; -function stepsEntry(day: string, value: number, deviceType = "watch") { +function stepsEntry( + day: string, + value: number, + deviceType = "watch", + externalSourceVersion?: string, +) { return { + ...(externalSourceVersion ? { externalSourceVersion } : {}), hkIdentifier: "HKQuantityTypeIdentifierStepCount", value, unit: "count", @@ -166,6 +173,65 @@ describe("an unchanged stats: re-post", () => { expect(tail.prs).toBe(1); }); + it("writes a re-post that changes only the source version, the device or the sleep stage", async () => { + // The overwrite contract of a `stats:` row covers these columns as much as + // the value: a re-post that only names a new HealthKit source version, a + // new device or a new sleep stage replaces the stored one. + const night = { + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 95, + unit: "category", + startDate: "2026-10-05T01:00:00.000Z", + endDate: "2026-10-05T02:35:00.000Z", + externalId: "stats:HKCategoryTypeIdentifierSleepAnalysis:2026-10-05", + sleepStage: 3, + }; + const seeded = await post([night]); + expect(seeded.data.inserted).toBe(1); + const sleepRow = () => + prisma.measurement.findFirstOrThrow({ + where: { userId, externalId: night.externalId }, + select: { syncVersion: true, sleepStage: true }, + }); + const sleepBefore = await sleepRow(); + const before = (await storedVersions()).map((r) => r.syncVersion); + resetTail(); + + const res = await post([ + stepsEntry(DAYS[0]!, 1000, "watch", "17.1"), // new source version + stepsEntry(DAYS[1]!, 1001, "phone"), // new device + { ...night, sleepStage: 4 }, // new sleep stage + ]); + expect(res.status).toBe(200); + expect(res.data.entries.map((e) => e.status)).toEqual([ + "updated", + "updated", + "updated", + ]); + + const after = await prisma.measurement.findMany({ + where: { userId, externalId: { startsWith: "stats:HKQuantity" } }, + orderBy: { externalId: "asc" }, + select: { + syncVersion: true, + externalSourceVersion: true, + deviceType: true, + }, + }); + expect(after.map((r) => r.syncVersion)).toEqual([ + before[0]! + 1, + before[1]! + 1, + before[2], + before[3], + ]); + expect(after[0]!.externalSourceVersion).toBe("17.1"); + expect(after[1]!.deviceType).toBe("phone"); + const sleepAfter = await sleepRow(); + expect(sleepAfter.sleepStage).toBe("DEEP"); + expect(sleepAfter.syncVersion).toBe(sleepBefore.syncVersion + 1); + expect(tail.rollups).toBe(1); + }); + it("resurrects a tombstoned row the old way", async () => { await prisma.measurement.updateMany({ where: { diff --git a/tests/integration/routes/workouts-canonical-window.test.ts b/tests/integration/routes/workouts-canonical-window.test.ts new file mode 100644 index 000000000..7dfdbd0cf --- /dev/null +++ b/tests/integration/routes/workouts-canonical-window.test.ts @@ -0,0 +1,215 @@ +/** + * The workout list and the workout detail resolve a duplicate cluster the same + * way, whatever range either of them read. + * + * - Two copies of one run thirty seconds apart on either side of a + * five-minute clock boundary: the list shows the Apple Health row, and the + * detail of the Withings copy names that row as `canonicalId`. The detail + * used to read only the clock slot of the requested row, found no twin, + * and declared the hidden copy canonical. + * - Runs at 09:58 (Withings), 10:02 (Apple Health) and 10:06 (Withings): a + * whole-history read pairs 09:58 with 10:02 and leaves 10:06 alone. A list + * filtered from 10:00 shows 10:02 and 10:06, the detail of every row agrees + * with that, and the session before 10:06 is 10:02. + * - The batch write clusters what it is about to insert together with the + * stored workouts around it: with 09:58 stored, a batch of 10:02 (Apple + * Health) and 10:06 (entered by hand) keeps both, where clustering the + * batch alone dropped 10:06 as a twin of 10:02 although every read shows + * it as a session of its own. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { GET as getDetail } from "@/app/api/workouts/[id]/route"; +import { GET as getList } from "@/app/api/workouts/route"; +import { POST as postBatch } from "@/app/api/workouts/batch/route"; + +import { callRoute, type RouteAuth } from "../../kit/call-route"; +import { makeSession, makeUser, makeWorkout } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +beforeEach(() => truncateAllTables(prisma)); + +const DAY_MS = 24 * 60 * 60 * 1000; +/** A day ten days back, so the list's default window always holds it. */ +const DAY = new Date(Date.now() - 10 * DAY_MS).toISOString().slice(0, 10); +/** An instant on that day, `HH:MM:SS` in UTC. */ +const at = (time: string) => `${DAY}T${time}.000Z`; + +interface ListBody { + workouts: Array<{ id: string }>; +} +interface DetailBody { + id: string; + canonicalId: string; + previousWorkoutId: string | null; +} + +async function run( + user: Pick, + startedAt: string, + source: "APPLE_HEALTH" | "WITHINGS", +) { + return makeWorkout(user, { + startedAt: new Date(startedAt), + endedAt: new Date(new Date(startedAt).getTime() + 30 * 60 * 1000), + durationSec: 30 * 60, + source, + externalId: `${source}-${startedAt}`, + }); +} + +async function list(auth: RouteAuth, query: Record = {}) { + const res = await callRoute(getList, { + method: "GET", + route: "/api/workouts", + query, + auth, + }); + expect(res.status).toBe(200); + return res.data!.workouts.map((w) => w.id); +} + +async function detail(auth: RouteAuth, id: string) { + const res = await callRoute(getDetail, { + method: "GET", + route: "/api/workouts/{id}", + params: { id }, + query: { compact: "1" }, + auth, + }); + expect(res.status).toBe(200); + return res.data!; +} + +describe("list and detail agree on the canonical workout", () => { + it("resolves a twin across a clock-slot boundary to the row the list shows", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + const apple = await run(user, at("08:04:50"), "APPLE_HEALTH"); + const withings = await run(user, at("08:05:20"), "WITHINGS"); + + expect(await list(auth)).toEqual([apple.id]); + expect((await detail(auth, apple.id)).canonicalId).toBe(apple.id); + // The deep link to the hidden copy lands on the row the list shows. + expect((await detail(auth, withings.id)).canonicalId).toBe(apple.id); + }); + + it("draws the clusters of a filtered range as a read of the whole history does", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + const early = await run(user, at("09:58:00"), "WITHINGS"); + const middle = await run(user, at("10:02:00"), "APPLE_HEALTH"); + const late = await run(user, at("10:06:00"), "WITHINGS"); + + expect(await list(auth)).toEqual([late.id, middle.id]); + // Filtered from 10:00 the list no longer holds the 09:58 run, and still + // draws the clusters the whole history draws: 10:06 is a session of its + // own, not a twin of 10:02. + expect(await list(auth, { since: at("10:00:00") })).toEqual([ + late.id, + middle.id, + ]); + // Up to 10:04 the list must still hide 10:02's twin from 09:58 and show + // 10:02, though the read stops before 10:06. + expect( + await list(auth, { + since: at("09:59:00"), + until: at("10:04:00"), + }), + ).toEqual([middle.id]); + + expect((await detail(auth, early.id)).canonicalId).toBe(middle.id); + expect((await detail(auth, middle.id)).canonicalId).toBe(middle.id); + const lateDetail = await detail(auth, late.id); + expect(lateDetail.canonicalId).toBe(late.id); + expect(lateDetail.previousWorkoutId).toBe(middle.id); + // The session before the 10:02 run is none: 09:58 is its own twin. + expect((await detail(auth, middle.id)).previousWorkoutId).toBeNull(); + }); +}); + +describe("the batch write draws clusters where the reads draw them", () => { + it("keeps a workout the reads show as a session of its own", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + await run(user, at("09:58:00"), "WITHINGS"); + + const res = await callRoute<{ + entries: Array<{ index: number; status: string }>; + }>(postBatch, { + method: "POST", + route: "/api/workouts/batch", + body: { + workouts: [ + { + sportType: "running", + startedAt: at("10:02:00"), + endedAt: at("10:32:00"), + source: "APPLE_HEALTH", + externalId: "apple-1002", + }, + { + sportType: "running", + startedAt: at("10:06:00"), + endedAt: at("10:36:00"), + source: "MANUAL", + externalId: "manual-1006", + }, + ], + }, + auth, + }); + + expect(res.status).toBe(200); + expect(res.data!.entries.map((e) => e.status)).toEqual([ + "inserted", + "inserted", + ]); + const stored = await prisma.workout.findMany({ + where: { userId: user.id }, + orderBy: { startedAt: "asc" }, + select: { id: true, externalId: true }, + }); + expect(await list(auth)).toEqual([stored[2]!.id, stored[1]!.id]); + }); + + it("still drops the twin a batch carries of its own winner", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + + const res = await callRoute<{ + entries: Array<{ index: number; status: string }>; + }>(postBatch, { + method: "POST", + route: "/api/workouts/batch", + body: { + workouts: [ + { + sportType: "running", + startedAt: at("08:04:50"), + endedAt: at("08:34:50"), + source: "APPLE_HEALTH", + externalId: "apple-0804", + }, + { + sportType: "running", + startedAt: at("08:05:20"), + endedAt: at("08:35:20"), + source: "MANUAL", + externalId: "manual-0805", + }, + ], + }, + auth, + }); + + expect(res.status).toBe(200); + expect(res.data!.entries.map((e) => e.status)).toEqual([ + "inserted", + "duplicate", + ]); + }); +}); diff --git a/tests/property/fast-check.ts b/tests/property/fast-check.ts index f5dd632da..283198c96 100644 --- a/tests/property/fast-check.ts +++ b/tests/property/fast-check.ts @@ -8,8 +8,13 @@ * prints the seed and the shrunk counterexample, and * `FC_SEED= pnpm test ` replays it exactly. * - * `FC_NUM_RUNS` scales the number of generated cases (the nightly run uses - * more). The default keeps every property file well under a second or two. + * `FC_RUNS_FACTOR` multiplies every property's own case count (the nightly + * run sets it above 1). A factor rather than a count: each property states + * the number of cases its cost allows, a cheap law a few hundred and a + * database round trip a dozen, and a nightly count applied to all of them + * alike ran the round trip a hundred times against a timeout sized for + * twelve. `propertyTimeout` grows a test's own timeout by the same factor. + * The defaults keep every property file well under a second or two. */ import fc from "fast-check"; @@ -21,22 +26,41 @@ function positiveInt(raw: string | undefined): number | null { return Number.isInteger(value) && value > 0 ? value : null; } +function positiveNumber(raw: string | undefined): number | null { + if (!raw) return null; + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? value : null; +} + export const PROPERTY_SEED = positiveInt(process.env.FC_SEED) ?? DEFAULT_SEED; -const SCALE = positiveInt(process.env.FC_NUM_RUNS); +/** The multiplier on every property's case count; 1 on a pull request. */ +export const RUNS_FACTOR = positiveNumber(process.env.FC_RUNS_FACTOR) ?? 1; -/** Parameters for `fc.assert`; `numRuns` is the PR-run default per property. */ +/** + * Parameters for `fc.assert`; `numRuns` is the property's own case count on a + * pull request, multiplied by `FC_RUNS_FACTOR` when it is set. + */ export function propertyParameters( numRuns = 100, overrides: fc.Parameters = {}, ): fc.Parameters { return { seed: PROPERTY_SEED, - numRuns: SCALE ?? numRuns, + numRuns: Math.max(1, Math.round(numRuns * RUNS_FACTOR)), ...overrides, }; } +/** + * A test timeout for a property whose cases are expensive, grown by the same + * factor as its case count, so a nightly run with more cases does not run + * into a timeout sized for the pull-request count. + */ +export function propertyTimeout(baseMs: number): number { + return Math.ceil(baseMs * Math.max(1, RUNS_FACTOR)); +} + /** Run a property with the shared parameters. */ export function assertProperty( property: fc.IProperty,