From c400e2aa887aaac0ad5f01bdec58a508cc0153e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 11 Jul 2026 00:18:23 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.23=20=E2=80=94=20sleep?= =?UTF-8?q?=20disagreement=20on=20the=20dashboard;=20Apple=20Health=20medi?= =?UTF-8?q?cation=20groundwork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sleep source-discrepancy annotation now reaches the surfaces: SleepNight carries the MAIN session's annotation (naps never cross-compare), the analytics slim slice / dashboard snapshot / iOS summary thread it additively, and the dashboard sleep tile renders the shared discreet marker (extracted from the sleep panel — one implementation, two surfaces). Served totals unchanged, pinned by tests. Apple Health medications groundwork (server side of the medication-import request): IntakeSource gains APPLE_HEALTH; medications accept an external mirror identity (source-exclusive by policy) and dose imports carry the HK dose-event UUID — both idempotent per stable key, with the concurrent-create race resolved to the winner. Migration 0239, additive only. Entries without the new fields behave byte-identically. Hardening: doctor-report aggregation + PDF charts compute ranges with loops instead of spread calls — a report over a sample-dense year cannot overflow the call stack (the class fixed for the Google Health full sync). --- CHANGELOG.md | 20 ++ docs/api/openapi.yaml | 58 +++- package.json | 2 +- .../migration.sql | 62 ++++ prisma/schema.prisma | 43 +++ public/sw.js | 2 +- src/app/api/analytics/route.ts | 5 + .../dashboard/summary/__tests__/route.test.ts | 84 ++++++ src/app/api/dashboard/summary/route.ts | 29 ++ .../api/medications/__tests__/route.test.ts | 133 +++++++++ .../intake/bulk/__tests__/route.test.ts | 270 +++++++++++++++++ src/app/api/medications/intake/bulk/route.ts | 205 ++++++++++--- src/app/api/medications/route.ts | 282 ++++++++++++------ src/app/page-client.tsx | 18 ++ .../charts/__tests__/trend-card.test.tsx | 47 +++ src/components/charts/trend-card.tsx | 21 ++ src/components/insights/sleep-hypnogram.tsx | 131 +------- .../sleep-source-discrepancy-marker.tsx | 148 +++++++++ .../analytics/__tests__/sleep-night.test.ts | 64 ++++ src/lib/analytics/merge-slim-thick.ts | 16 + src/lib/analytics/sleep-night.ts | 38 ++- src/lib/analytics/summaries-slice.ts | 31 +- src/lib/dashboard/snapshot.ts | 12 + src/lib/doctor-report-data.ts | 37 ++- src/lib/doctor-report-pdf-core.ts | 11 +- src/lib/medications/scheduling/slot-upsert.ts | 23 +- src/lib/openapi/routes/insights/schemas.ts | 19 ++ .../validations/medication/create-update.ts | 38 ++- src/types/analytics.ts | 12 + 29 files changed, 1585 insertions(+), 276 deletions(-) create mode 100644 prisma/migrations/0239_apple_health_medications/migration.sql create mode 100644 src/components/insights/sleep-source-discrepancy-marker.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 585c9f2ad..17dfaa1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## [1.28.23] — 2026-07-11 — Sleep disagreement visible on the dashboard; Apple Health medication groundwork + +When two sources disagree about last night's sleep — a sleep mat's time in bed +against a watch's time asleep — the dashboard sleep tile now carries a small +marker next to the value. The tooltip names each source and its total, so a +surprising number explains itself instead of silently picking a side. The same +marker backs the sleep panel; the shown total is unchanged either way. + +Groundwork for importing medications and their taken doses from Apple Health +(iOS 26+): the server now accepts medications mirrored from an external source +and dose events carrying a stable external identity, imported idempotently — +re-syncs never duplicate. A mirrored medication is source-exclusive, so an +imported dose can never double-count against a manually logged one. The +matching client support arrives with a future app update. + +Hardening: the doctor-report aggregation and PDF charts now compute their +ranges with loops instead of spread calls, so a report over a sample-dense year +(per-beat heart rate, sensor glucose) cannot overflow the call stack — the same +failure class fixed for the Google Health full sync. + ## [1.28.22] — 2026-07-10 — Full sync survives dense histories; quieter document chrome A full Google Health sync no longer fails on sample-dense accounts. Collecting diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e53b7cc15..c8f4b3d37 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.22 + version: 1.28.23 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -10895,6 +10895,20 @@ components: maxLength: 20 pattern: ^\d+$ - type: "null" + externalSource: + description: "Marks the medication as MIRRORED from an external medication list. Only `APPLE_HEALTH` (the iOS 26+ + HealthKit Medications sync) is accepted. Must be supplied together with `externalId`. A mirrored medication + is source-exclusive: dose events from that source attach only to it, and the app's own dose-logging UI stays + off it." + type: string + const: APPLE_HEALTH + externalId: + description: "Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max + 128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns + the existing medication with status 200 instead of minting a duplicate." + type: string + minLength: 1 + maxLength: 128 startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -10936,7 +10950,9 @@ components: schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication - still requires at least one schedule entry.' + still requires at least one schedule entry. v1.28 — `externalSource` + `externalId` (both-or-neither) mark the + medication as mirrored from Apple Health; re-posting the same pair is idempotent and returns the existing + medication with status 200.' MedicationScheduleInput: type: object properties: @@ -22621,6 +22637,44 @@ components: - summary - entries additionalProperties: false + sleepSourceDiscrepancy: + description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main + session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays + the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep + tile's headline. Null when the writers agree or the sleep module is off; optional for older cached + snapshots. + anyOf: + - type: object + properties: + deltaMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + sources: + type: array + items: + type: object + properties: + source: + type: string + deviceType: + anyOf: + - type: string + - type: "null" + asleepMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - source + - deviceType + - asleepMinutes + additionalProperties: false + required: + - deltaMinutes + - sources + additionalProperties: false + - type: "null" required: - summaries - lastSeenByType diff --git a/package.json b/package.json index 4f021210b..5bbfc343e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.22", + "version": "1.28.23", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0239_apple_health_medications/migration.sql b/prisma/migrations/0239_apple_health_medications/migration.sql new file mode 100644 index 000000000..ed7f6e071 --- /dev/null +++ b/prisma/migrations/0239_apple_health_medications/migration.sql @@ -0,0 +1,62 @@ +-- v1.28 — Apple Health medications + intake (server contract, #423). +-- +-- iOS 26+ HealthKit exposes the user's medication list +-- (`HKUserAnnotatedMedication`, stable `medicationConceptIdentifier`) and +-- dose events (`HKMedicationDoseEvent`, stable UUID, taken/skipped), +-- read-only. The server side is purely additive: a provenance pair on +-- `medications` (a med with `external_source` set is MIRRORED from that +-- source — source-exclusive, no cross-source slot collapse) and an +-- external dose-event id on `medication_intake_events` that makes the +-- bulk re-sync idempotent. + +-- ── 1. intake_source — append the Apple Health source ────────────────── +-- +-- Purely-additive enum extension; no row touched. `ADD VALUE IF NOT +-- EXISTS` makes the rerun safe. The new value is NOT used elsewhere in +-- this migration (the columns below are typed `intake_source` but no row +-- is inserted or updated), so it is safe to extend the enum in the same +-- step — Postgres only forbids USING a freshly-added enum value inside +-- the transaction that added it. +ALTER TYPE "intake_source" ADD VALUE IF NOT EXISTS 'APPLE_HEALTH'; + +-- ── 2. medications — mirrored-medication provenance ──────────────────── +-- +-- `external_source` non-NULL marks the row a read-only mirror of an +-- external medication list; `external_id` carries the source's stable +-- concept identifier (HealthKit `medicationConceptIdentifier`). Both +-- nullable, no default, no backfill: every existing row stays a native +-- HealthLog medication. +ALTER TABLE "medications" + ADD COLUMN "external_source" "intake_source", + ADD COLUMN "external_id" TEXT; + +-- One mirror row per external concept per user. A plain (non-partial) +-- unique matching the schema's `@@unique([userId, externalSource, +-- externalId])`: Postgres treats NULLs as distinct in unique indexes, so +-- the existing rows (both columns NULL) are unaffected — only genuinely +-- mirrored medications occupy a slot. The create route pre-queries the +-- triple and returns the existing medication; this index is the race +-- backstop (concurrent create surfaces P2002, which the route resolves +-- to the winning row). +CREATE UNIQUE INDEX "medications_user_id_external_source_external_id_key" + ON "medications" ("user_id", "external_source", "external_id"); + +-- ── 3. medication_intake_events — external dose-event id ─────────────── +-- +-- Carries the HealthKit `HKMedicationDoseEvent` UUID. Nullable, no +-- backfill: NULL for every non-mirrored row. +ALTER TABLE "medication_intake_events" + ADD COLUMN "external_id" TEXT; + +-- Idempotent re-import: one LIVE row per `(user_id, external_id)`. A +-- partial unique like the live-row slot unique from migration 0121 — +-- restricted to non-NULL externalIds (rows without one, i.e. everything +-- pre-Apple, stay unconstrained and out of the index) and to live rows +-- (`deleted_at IS NULL`, so a tombstone never blocks the sync ledger). +-- The bulk intake route pre-checks the batch's externalIds and reports +-- replays as duplicates; this index is the race backstop. Prisma can't +-- express a partial-predicate unique, so this is hand-written and the +-- schema keeps the full `@@unique` for the compound where-key. +CREATE UNIQUE INDEX "medication_intake_events_user_external_id_live_key" + ON "medication_intake_events" ("user_id", "external_id") + WHERE "external_id" IS NOT NULL AND "deleted_at" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c2dc16c60..ca2b01fd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2231,6 +2231,20 @@ model Medication { /// `notificationPrefs.medication.reorderLeadDays` default. Additive; no /// backfill. reorderLeadDays Int? @map("reorder_lead_days") + /// v1.28 — provenance of a MIRRORED medication. Non-NULL marks this row a + /// read-only mirror of an external medication list (today only + /// APPLE_HEALTH — the iOS 26+ HealthKit Medications sync). Source- + /// exclusive by policy: dose events from that source attach only to + /// medications mirrored from it, and the app's own dose-logging UI is + /// expected to stay off a mirrored medication client-side. NULL = a + /// native HealthLog medication. + externalSource IntakeSource? @map("external_source") + /// v1.28 — stable external identifier of the mirrored medication (the + /// HealthKit `medicationConceptIdentifier`). Paired with `externalSource` + /// (both set or both NULL; the create route enforces the pairing). Drives + /// the idempotent re-mirror on `POST /api/medications` via the + /// `(userId, externalSource, externalId)` unique below. + externalId String? @map("external_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -2276,6 +2290,13 @@ model Medication { user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// v1.28 — one mirror row per external medication concept per user + /// (migration 0239). Postgres treats NULLs as distinct in unique + /// indexes, so the vast majority of rows (both columns NULL) are + /// unaffected; only genuinely mirrored medications occupy a slot. A + /// re-mirror of the same concept id returns the existing row (the + /// create route pre-queries the triple and catches the P2002 race). + @@unique([userId, externalSource, externalId]) @@index([userId]) @@index([treatmentClass]) @@map("medications") @@ -2488,6 +2509,11 @@ enum IntakeSource { API REMINDER IMPORT + /// v1.28 — Apple Health medication sync (iOS 26+ HealthKit Medications + /// API). Spelled exactly like `MeasurementSource.APPLE_HEALTH`; the iOS + /// DTO mirrors this wire spelling. Doubles as the provenance label on + /// `Medication.externalSource` for mirrored medications. + APPLE_HEALTH @@map("intake_source") } @@ -2589,6 +2615,13 @@ model MedicationIntakeEvent { /// stamp freezes history: editing `Medication.unitsPerDose` later /// never changes what an already-taken dose consumed. inventoryConsumption Json? @map("inventory_consumption") + /// v1.28 — external dose-event identifier (the HealthKit + /// `HKMedicationDoseEvent` UUID). Drives idempotent re-import on the + /// bulk intake route: a re-synced Apple dose dedups against the live + /// `(userId, externalId)` unique below instead of inserting again + /// (first-write-wins per external dose event). NULL for every + /// non-mirrored row. + externalId String? @map("external_id") // v1.7.0 sync — server-set mutation timestamp. The model had no // `updatedAt`; the offline-sync keyset feed orders on it, so it is // added here. `@updatedAt` makes Prisma maintain it on every write. @@ -2624,6 +2657,16 @@ model MedicationIntakeEvent { /// full `@@unique` for the `userId_medicationId_scheduledFor_source` /// compound where-key. @@unique([userId, medicationId, scheduledFor, source]) + /// v1.28 — `medication_intake_events_user_external_id_live_key` is a + /// partial UNIQUE index over `(user_id, external_id) WHERE external_id + /// IS NOT NULL AND deleted_at IS NULL` — see migration 0239. The bulk + /// intake route pre-checks the batch's externalIds and reports replays + /// as duplicates; the index is the race backstop (a concurrent insert + /// surfaces P2002, which the route resolves to the winning row). Prisma + /// can't express a partial-predicate unique (same as the live-row slot + /// unique above, migration 0121), so the schema keeps the full + /// `@@unique` for the `userId_externalId` compound where-key. + @@unique([userId, externalId]) @@index([userId, medicationId, scheduledFor]) // v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed // (`where userId` ordered by `(updatedAt asc, id asc)`). diff --git a/public/sw.js b/public/sw.js index 4e0974795..a26ea98f1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.22"; + /* @sw-version-fallback */ "v1.28.23"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index d8f0e4e24..7225c11d0 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -485,6 +485,11 @@ async function buildAnalyticsResponse(user: AuthedUser) { correlations, healthScore, sleepStages, + // v1.28.x — the latest night's source-discrepancy annotation, computed + // inside the slim slice's per-night post-pass. Additive: the dashboard + // sleep tile marks the headline with a discreet "sources disagree" + // hint; clients that don't read the field stay unchanged. + sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy, // v1.4.34 IW-B — per-type freshness map drives the dashboard's // staleness caption on each ``. Additive: clients that // don't read the field stay unchanged. diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 5160dd652..466b89dc4 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -572,6 +572,14 @@ describe("GET /api/dashboard/summary", () => { latestValue: number | null; unit: string | null; sleepStages: Record | null; + sleepSourceDiscrepancy: { + deltaMinutes: number; + sources: Array<{ + source: string; + deviceType: string | null; + asleepMinutes: number; + }>; + } | null; }>; }; }; @@ -585,6 +593,82 @@ describe("GET /api/dashboard/summary", () => { expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2); expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2); expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2); + // v1.28.x — the additive discrepancy field is present and null-safe: + // source-less single-writer rows carry no annotation. + expect(sleep?.sleepSourceDiscrepancy).toBeNull(); + // Every non-sleep kind pins the additive field to null. + for (const m of body.data.metrics) { + if (m.id !== "sleep") expect(m.sleepSourceDiscrepancy).toBeNull(); + } + }); + + it("carries the latest night's source-discrepancy annotation on the sleep tile (v1.28.x)", async () => { + // Two writers disagree on the night: granular Apple (410 asleep) next + // to a bare Withings aggregate claiming 615. Apple wins the served + // total; the additive annotation surfaces the disagreement. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const wake = new Date("2026-06-04T06:00:00.000Z"); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "SLEEP_DURATION", + _count: { _all: 4 }, + _max: { measuredAt: wake }, + }, + ] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + value: 240, + measuredAt: new Date("2026-06-04T00:00:00.000Z"), + sleepStage: "CORE", + source: "APPLE_HEALTH", + }, + { + value: 90, + measuredAt: new Date("2026-06-04T02:00:00.000Z"), + sleepStage: "DEEP", + source: "APPLE_HEALTH", + }, + { + value: 80, + measuredAt: new Date("2026-06-04T04:00:00.000Z"), + sleepStage: "REM", + source: "APPLE_HEALTH", + }, + { + value: 615, + measuredAt: wake, + sleepStage: "ASLEEP", + source: "WITHINGS", + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + sleepSourceDiscrepancy: { + deltaMinutes: number; + sources: Array<{ + source: string; + deviceType: string | null; + asleepMinutes: number; + }>; + } | null; + }>; + }; + }; + const sleep = body.data.metrics.find((m) => m.id === "sleep"); + expect(sleep, "sleep tile must be emitted").toBeDefined(); + // Served total stays the winning writer's number (410 min → 6.83 h). + expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2); + expect(sleep?.sleepSourceDiscrepancy).toEqual({ + deltaMinutes: 205, + sources: [ + { source: "WITHINGS", deviceType: null, asleepMinutes: 615 }, + { source: "APPLE_HEALTH", deviceType: null, asleepMinutes: 410 }, + ], + }); }); it("emits the sleep-rhythm DTO (sleep-debt + chronotype) computed from the foundation modules (v1.17.0)", async () => { diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 93dd872bf..dbf4490f2 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -60,6 +60,7 @@ import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { summarizeSleepNights, reconstructSleepNights, + type SleepSourceDiscrepancy, type SleepStageRow, } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; @@ -130,6 +131,16 @@ interface MetricCard { * headline `latestValue`. */ sleepStages: Partial> | null; + /** + * v1.28.x — source-discrepancy annotation for the headline night's MAIN + * session, sleep tile only; `null` for every other kind and for a night + * whose writers agree. Additive (the established wire pattern for the + * iOS-locked shape): observational only — `latestValue` stays the + * winning writer's total; clients render a discreet "sources disagree" + * hint next to the number. Minutes are whole (rounded), matching the + * `/api/sleep/night` serialisation. + */ + sleepSourceDiscrepancy: SleepSourceDiscrepancy | null; trend: "up" | "down" | "flat" | "unknown"; sparkline: number[]; updatedAt: string | null; @@ -707,6 +718,7 @@ async function buildDashboardSummary( unitKey: METRIC_UNIT_KEYS.weight, unit: null, sleepStages: null, + sleepSourceDiscrepancy: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -747,6 +759,7 @@ async function buildDashboardSummary( unitKey: METRIC_UNIT_KEYS.bloodPressure, unit: null, sleepStages: null, + sleepSourceDiscrepancy: null, trend: trendOf(sysSpark), sparkline: sysSpark, updatedAt: @@ -776,6 +789,7 @@ async function buildDashboardSummary( unitKey: METRIC_UNIT_KEYS.pulse, unit: null, sleepStages: null, + sleepSourceDiscrepancy: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -803,6 +817,7 @@ async function buildDashboardSummary( unitKey: METRIC_UNIT_KEYS.bodyFat, unit: null, sleepStages: null, + sleepSourceDiscrepancy: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, @@ -862,6 +877,19 @@ async function buildDashboardSummary( unit: "h", sleepStages: stageHours && Object.keys(stageHours).length > 0 ? stageHours : null, + // Additive, observational: the headline night's MAIN session saw + // two writer buckets with clearly different asleep totals. Round + // to whole minutes, matching the `/api/sleep/night` serialisation. + sleepSourceDiscrepancy: night?.sourceDiscrepancy + ? { + deltaMinutes: Math.round(night.sourceDiscrepancy.deltaMinutes), + sources: night.sourceDiscrepancy.sources.map((b) => ({ + source: b.source, + deviceType: b.deviceType, + asleepMinutes: Math.round(b.asleepMinutes), + })), + } + : null, trend: trendOf(nightSpark), sparkline: nightSpark, updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, @@ -881,6 +909,7 @@ async function buildDashboardSummary( unitKey: METRIC_UNIT_KEYS[kind], unit: null, sleepStages: null, + sleepSourceDiscrepancy: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index ebae86353..17a683f29 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany: vi.fn().mockResolvedValue([]), + // v1.28 — the Apple-mirror idempotency pre-query. + findFirst: vi.fn().mockResolvedValue(null), create: vi.fn(), }, medicationIntakeEvent: { @@ -497,3 +499,134 @@ describe("POST /api/medications — as-needed (v1.16.11, #316)", () => { ); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.28 — Apple Health mirrored medications (#423) +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications — Apple Health mirror (v1.28)", () => { + const MIRROR_BODY = { + name: "Ramipril", + dose: "5 mg", + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-1", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }; + + function lastCreateData(): Record { + const calls = vi.mocked(prisma.medication.create).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return (calls[calls.length - 1][0] as { data: Record }) + .data; + } + + it("persists externalSource + externalId field-by-field on create", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(201); + const data = lastCreateData(); + expect(data.externalSource).toBe("APPLE_HEALTH"); + expect(data.externalId).toBe("hk-concept-1"); + // The pre-query probed the mirror triple, user-scoped. + expect(prisma.medication.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-1", + }, + }), + ); + }); + + it("returns the EXISTING medication (200) on a re-post of the same triple — no duplicate row", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "med-existing", + userId: "user-1", + name: "Ramipril", + dose: "5 mg", + unitsPerDose: 1, + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-1", + schedules: [], + } as never); + + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { id: string; externalId: string; category: string }; + }; + expect(body.data.id).toBe("med-existing"); + expect(body.data.externalId).toBe("hk-concept-1"); + expect(body.data.category).toBe("OTHER"); + // Idempotent replay: nothing written. + expect(prisma.medication.create).not.toHaveBeenCalled(); + }); + + it("resolves a concurrent P2002 race on the mirror triple to the winning row (200)", async () => { + // Pre-query misses; the create then loses the race against a + // concurrent mirror of the same concept id. + vi.mocked(prisma.medication.findFirst) + .mockResolvedValueOnce(null as never) + .mockResolvedValueOnce({ + id: "med-winner", + userId: "user-1", + name: "Ramipril", + dose: "5 mg", + unitsPerDose: 1, + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-1", + schedules: [], + } as never); + vi.mocked(prisma.medication.create).mockRejectedValueOnce( + Object.assign(new Error("unique"), { code: "P2002" }), + ); + + const res = await POST(postReq(MIRROR_BODY)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { id: string } }; + expect(body.data.id).toBe("med-winner"); + }); + + it("422s when externalSource is supplied without externalId", async () => { + const res = await POST(postReq({ ...MIRROR_BODY, externalId: undefined })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + expect( + body.details.issues.some((i) => + i.message.includes("must be supplied together"), + ), + ).toBe(true); + expect(prisma.medication.create).not.toHaveBeenCalled(); + }); + + it("422s when externalId is supplied without externalSource", async () => { + const res = await POST( + postReq({ ...MIRROR_BODY, externalSource: undefined }), + ); + expect(res.status).toBe(422); + expect(prisma.medication.create).not.toHaveBeenCalled(); + }); + + it("422s on a non-Apple externalSource value (server-owned sources stay closed)", async () => { + const res = await POST(postReq({ ...MIRROR_BODY, externalSource: "WEB" })); + expect(res.status).toBe(422); + expect(prisma.medication.create).not.toHaveBeenCalled(); + }); + + it("a plain create keeps today's behavior — no external columns forwarded", async () => { + const res = await POST( + postReq({ + name: "Ramipril", + dose: "5 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + ); + expect(res.status).toBe(201); + const data = lastCreateData(); + expect("externalSource" in data).toBe(false); + expect("externalId" in data).toBe(false); + }); +}); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 9e549a248..84fd54a0e 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -1088,3 +1088,273 @@ describe("POST /api/medications/intake/bulk — v1.16.10 inventory consumption", expect(consumeForIntake).not.toHaveBeenCalled(); }); }); + +// ──────────────────────────────────────────────────────────────────── +// v1.28 — Apple Health dose events (#423) +// ──────────────────────────────────────────────────────────────────── + +describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events", () => { + const MIRRORED_MED = { + id: "med-apple", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ], + }; + + beforeEach(() => { + // 20:30 Berlin on 2026-06-15 — past both fixture slots for the day. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-15T18:30:00.000Z")); + // The ownership read surfaces the mirror flag for the source gate. + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-apple", externalSource: "APPLE_HEALTH" }, + ] as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue( + MIRRORED_MED as never, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("422s the batch when an APPLE_HEALTH entry targets a NON-mirrored medication", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-native", externalSource: null }, + ] as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-native", + takenAt: "2026-06-15T12:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "hk-dose-1", + }, + ], + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe( + "medications.intake.bulk.apple_health_not_mirrored", + ); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("422s an APPLE_HEALTH entry without externalId (both-or-neither refine)", async () => { + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-apple", + takenAt: "2026-06-15T12:00:00.000Z", + source: "APPLE_HEALTH", + }, + ], + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid"); + }); + + it("422s an externalId without source APPLE_HEALTH", async () => { + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-apple", + takenAt: "2026-06-15T12:00:00.000Z", + externalId: "hk-dose-1", + }, + ], + }), + ); + expect(res.status).toBe(422); + }); + + it("reports a replayed external dose-event UUID as duplicate without writing (idempotent re-sync)", async () => { + // The batch pre-check finds the UUID already live. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { id: "row-existing", externalId: "hk-dose-1" }, + ] as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-apple", + takenAt: "2026-06-15T12:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "hk-dose-1", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + duplicates: number; + inserted: number; + entries: Array<{ status: string; id?: string }>; + }; + }; + expect(body.data.duplicates).toBe(1); + expect(body.data.inserted).toBe(0); + expect(body.data.entries[0].status).toBe("duplicate"); + expect(body.data.entries[0].id).toBe("row-existing"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); + + it("inserts a fresh Apple dose with source APPLE_HEALTH + externalId stored; a same-batch repeat dedups", async () => { + // Dedup pre-check: nothing live for the batch's UUIDs. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce( + [] as never, + ); + // 14:00 Berlin — off-band, so the take records standalone (ad-hoc). + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({ + id: "row-apple", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-apple", + takenAt: "2026-06-15T12:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "hk-dose-2", + }, + // The same dose event repeated inside ONE batch (a client-side + // pagination overlap) — must dedup in-memory, not double-insert. + { + medicationId: "med-apple", + takenAt: "2026-06-15T12:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "hk-dose-2", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + inserted: number; + duplicates: number; + entries: Array<{ status: string; id?: string }>; + }; + }; + expect(body.data.inserted).toBe(1); + expect(body.data.duplicates).toBe(1); + expect(body.data.entries[0].status).toBe("inserted"); + expect(body.data.entries[1].status).toBe("duplicate"); + expect(body.data.entries[1].id).toBe("row-apple"); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + source: "APPLE_HEALTH", + externalId: "hk-dose-2", + takenAt: new Date("2026-06-15T12:00:00.000Z"), + }), + }), + ); + }); + + it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => { + // Dedup pre-check first (empty), then the canonical-slot find returns + // the worker-minted pending REMINDER row for the 19:00 slot. + vi.mocked(prisma.medicationIntakeEvent.findMany) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([ + { + id: "row-evening", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T17:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-evening", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-apple", + takenAt: "2026-06-15T17:30:00.000Z", + source: "APPLE_HEALTH", + externalId: "hk-dose-3", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { updated: number; entries: Array<{ status: string }> }; + }; + expect(body.data.updated).toBe(1); + expect(body.data.entries[0].status).toBe("updated"); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "row-evening" }, + data: expect.objectContaining({ externalId: "hk-dose-3" }), + }), + ); + }); + + it("keeps a source-less entry byte-identical to today: source API, no externalId column", async () => { + // No entry carries an externalId, so the dedup pre-check must not + // fire a findMany at all — the hot path pays zero extra reads. + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({ + id: "row-adhoc", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-apple", + takenAt: "2026-06-15T12:00:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { inserted: number; entries: Array<{ status: string }> }; + }; + expect(body.data.inserted).toBe(1); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + const createArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock + .calls[0][0] as { data: Record }; + expect(createArgs.data.source).toBe("API"); + expect("externalId" in createArgs.data).toBe(false); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 33ee65c3a..ac85c7f82 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -20,6 +20,13 @@ * forceSlotInstant?, — ISO timestamp; pins a taken entry onto a * named real scheduled slot (server-validated * against the band anchors) + * source?, — "APPLE_HEALTH" only (v1.28 HealthKit dose + * import); absent keeps the default "API". + * Requires externalId and a medication + * mirrored from Apple Health. + * externalId?, — HK dose-event UUID (max 128); drives the + * idempotent re-sync dedup (first-write-wins + * per Apple dose) * } * * Response (always 200): processed / inserted / duplicates / skipped / @@ -77,39 +84,62 @@ const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; -const bulkEntrySchema = z.object({ - medicationId: z.string().min(1), - scheduledFor: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), - // v1.16.9 — shared plausibility bounds (no future beyond clock skew, - // 5-year floor), matching the single-intake create + edit paths. - takenAt: boundedTakenAtSchema.optional(), - skipped: z.boolean().optional().default(false), - idempotencyKey: z.string().min(1).max(128).optional(), - // v1.8.5 — optional per-entry injection site. Validated against the - // medication's effective allowed set; a disallowed site marks the - // entry skipped (`injection_site_not_allowed`) without failing the - // whole batch, matching the bulk endpoint's per-entry contract. - injectionSite: injectionSiteEnum.optional(), - // v1.15.20 — late-take "attribute anyway" pin, mirroring the single - // intake route: pin a taken entry onto a named real scheduled slot - // instead of the default window-band attribution. Validated server-side - // against the band anchors; an instant that is not a slot marks the - // entry skipped (`force_slot_invalid`) per the bulk per-entry contract. - // Ignored on non-taken entries (a pending echo / skip carries no take - // to attribute). - forceSlotInstant: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), - // v1.16.4 — per-entry dose override, mirroring the single intake - // route: free text (max 50 chars, like `Medication.dose`), persisted - // only on a taken entry. Absent = the configured medication dose - // applies. - doseTaken: z.string().trim().min(1).max(50).optional(), -}); +const bulkEntrySchema = z + .object({ + medicationId: z.string().min(1), + scheduledFor: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + // v1.16.9 — shared plausibility bounds (no future beyond clock skew, + // 5-year floor), matching the single-intake create + edit paths. + takenAt: boundedTakenAtSchema.optional(), + skipped: z.boolean().optional().default(false), + idempotencyKey: z.string().min(1).max(128).optional(), + // v1.8.5 — optional per-entry injection site. Validated against the + // medication's effective allowed set; a disallowed site marks the + // entry skipped (`injection_site_not_allowed`) without failing the + // whole batch, matching the bulk endpoint's per-entry contract. + injectionSite: injectionSiteEnum.optional(), + // v1.15.20 — late-take "attribute anyway" pin, mirroring the single + // intake route: pin a taken entry onto a named real scheduled slot + // instead of the default window-band attribution. Validated server-side + // against the band anchors; an instant that is not a slot marks the + // entry skipped (`force_slot_invalid`) per the bulk per-entry contract. + // Ignored on non-taken entries (a pending echo / skip carries no take + // to attribute). + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + // v1.16.4 — per-entry dose override, mirroring the single intake + // route: free text (max 50 chars, like `Medication.dose`), persisted + // only on a taken entry. Absent = the configured medication dose + // applies. + doseTaken: z.string().trim().min(1).max(50).optional(), + // v1.28 — Apple Health dose-event import (iOS 26+ HealthKit + // Medications API). Only the APPLE_HEALTH literal is accepted — the + // other IntakeSource values stay server-owned; absent keeps today's + // default ("API"). Must carry `externalId` (refine below), and may + // only target a medication mirrored from Apple Health (route-level + // 422). logStatus mapping is client-side: iOS ships taken/skipped + // shaped like every other entry. + source: z.literal("APPLE_HEALTH").optional(), + // v1.28 — the HealthKit `HKMedicationDoseEvent` UUID. Drives the + // idempotent re-sync: an entry whose `(userId, externalId)` already + // exists live reports `duplicate` (first-write-wins per Apple dose). + externalId: z.string().trim().min(1).max(128).optional(), + }) + .refine( + // v1.28 — both-or-neither: an Apple entry without its dose-event UUID + // cannot dedup, and a bare externalId without the source label has no + // defined provenance. + (e) => (e.source === "APPLE_HEALTH") === (e.externalId !== undefined), + { + message: "source APPLE_HEALTH and externalId must be supplied together", + path: ["externalId"], + }, + ); const bulkPayloadSchema = z.object({ entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), @@ -202,17 +232,70 @@ async function postBulk(request: NextRequest): Promise { where: { id: { in: medicationIds }, userId: user.id }, // v1.8.5 — pull the injection-tracking fields so a per-entry // `injectionSite` can be resolved + validated without an extra - // per-entry read. + // per-entry read. v1.28 — `externalSource` rides along so the + // Apple-entry mirror gate below needs no extra read either. select: { id: true, deliveryForm: true, trackInjectionSites: true, allowedInjectionSites: true, + externalSource: true, }, }); const ownedSet = new Set(ownedMedications.map((m) => m.id)); const medById = new Map(ownedMedications.map((m) => [m.id, m])); + // v1.28 — source-exclusive Apple-mirrored medications: an APPLE_HEALTH + // dose event may only attach to a medication mirrored from Apple Health + // (`Medication.externalSource === "APPLE_HEALTH"`). Targeting a native + // medication is a client contract violation (the iOS sync maps HK dose + // events onto the meds it mirrored itself), not a per-entry data + // problem, so the whole batch 422s with a stable errorCode the client + // can branch on. Unowned medicationIds keep the per-entry + // `medication_not_found` skip below. + const appleMismatch = entries.some( + (e) => + e.source === "APPLE_HEALTH" && + ownedSet.has(e.medicationId) && + medById.get(e.medicationId)?.externalSource !== "APPLE_HEALTH", + ); + if (appleMismatch) { + return apiError( + "An APPLE_HEALTH entry may only target a medication mirrored from Apple Health", + 422, + { errorCode: "medications.intake.bulk.apple_health_not_mirrored" }, + ); + } + + // v1.28 — idempotent re-sync dedup: one read collects every live row + // already carrying one of the batch's external dose-event UUIDs; the + // per-entry loop reports those entries as `duplicate` (first-write-wins + // per Apple dose) without touching the DB again. Fresh inserts register + // into the same map so an intra-batch repeat of a UUID dedups too. The + // partial `(user_id, external_id)` unique (migration 0239) is the race + // backstop. + const batchExternalIds = Array.from( + new Set( + entries + .map((e) => e.externalId) + .filter((id): id is string => id !== undefined), + ), + ); + const rowIdByExternalId = new Map(); + if (batchExternalIds.length > 0) { + const existingExternalRows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + externalId: { in: batchExternalIds }, + deletedAt: null, + }, + select: { id: true, externalId: true }, + }); + for (const row of existingExternalRows) { + if (row.externalId) rowIdByExternalId.set(row.externalId, row.id); + } + } + // v1.8.5 — the user's global exclusion deny-list, loaded once for the // whole batch (it is user-scoped, not per-medication). Only read when // at least one entry actually carries an `injectionSite`, so the hot @@ -262,6 +345,19 @@ async function postBulk(request: NextRequest): Promise { continue; } + // v1.28 — Apple dose-event replay: the batch pre-check found a live + // row already carrying this external UUID (or an earlier entry in + // THIS batch inserted it). First-write-wins per Apple dose — report + // `duplicate` with the existing row id so the sync cursor advances. + if (entry.externalId !== undefined) { + const existingRowId = rowIdByExternalId.get(entry.externalId); + if (existingRowId !== undefined) { + duplicates += 1; + results.push({ index: i, status: "duplicate", id: existingRowId }); + continue; + } + } + try { const incomingScheduledFor = entry.scheduledFor ?? entry.takenAt ?? new Date(); @@ -414,11 +510,15 @@ async function postBulk(request: NextRequest): Promise { isExplicitTaken, isExplicitSkip, idempotencyKey: entry.idempotencyKey ?? null, - createSource: "API", + // v1.28 — an Apple dose-event import stamps its provenance on a + // fresh row; every other caller keeps today's "API". + createSource: entry.source ?? "API", injectionSite: resolvedInjectionSite, attributionSource, // v1.16.4 — dose override only documents a consumed dose. doseTaken: (!entry.skipped && entry.doseTaken) || null, + // v1.28 — external dose-event UUID rides onto the landed row. + externalId: entry.externalId ?? null, }); if (applied.noDowngradeNoOp) { // C2 — pending echo onto an already-actioned slot. Report it as a @@ -524,10 +624,13 @@ async function postBulk(request: NextRequest): Promise { isExplicitTaken, isExplicitSkip, idempotencyKey: entry.idempotencyKey ?? null, - createSource: "API", + // v1.28 — Apple provenance on a fresh row (see above). + createSource: entry.source ?? "API", injectionSite: resolvedInjectionSite, attributionSource, doseTaken: (!entry.skipped && entry.doseTaken) || null, + // v1.28 — external dose-event UUID rides onto the landed row. + externalId: entry.externalId ?? null, }); if (applied.noDowngradeNoOp) { duplicates += 1; @@ -573,8 +676,15 @@ async function postBulk(request: NextRequest): Promise { scheduledFor: effectiveScheduledFor, takenAt: entry.takenAt ?? null, skipped: entry.skipped, - source: "API", + // v1.28 — Apple provenance on a standalone (ad-hoc) row; + // every other caller keeps today's "API". + source: entry.source ?? "API", idempotencyKey: entry.idempotencyKey ?? null, + // v1.28 — external dose-event UUID for the idempotent + // re-sync dedup. + ...(entry.externalId !== undefined && { + externalId: entry.externalId, + }), // v1.8.5 — site only on a resolved taken-injection entry. ...(resolvedInjectionSite !== null && { injectionSite: resolvedInjectionSite, @@ -612,6 +722,13 @@ async function postBulk(request: NextRequest): Promise { // only when the row's state actually changed (inserted / updated). A // pending echo, duplicate, or no-downgrade no-op leaves the visible // dose state untouched, so it must not wake the user's other devices. + // v1.28 — register the landed row under its external dose-event + // UUID so a repeat of the same UUID later in THIS batch dedups + // in-memory instead of tripping the partial unique. + const landed = results[results.length - 1]; + if (entry.externalId !== undefined && landed?.id !== undefined) { + rowIdByExternalId.set(entry.externalId, landed.id); + } const lastStatus = results[results.length - 1]?.status; if (lastStatus === "inserted" || lastStatus === "updated") { const scheduledForIso = effectiveScheduledFor.toISOString(); @@ -638,7 +755,19 @@ async function postBulk(request: NextRequest): Promise { where: { idempotencyKey: entry.idempotencyKey }, select: { id: true }, }) - : null; + : // v1.28 — an Apple entry that raced the partial + // `(user_id, external_id)` unique (concurrent batches syncing + // the same dose event) resolves to the winning live row. + entry.externalId + ? await prisma.medicationIntakeEvent.findFirst({ + where: { + userId: user.id, + externalId: entry.externalId, + deletedAt: null, + }, + select: { id: true }, + }) + : null; duplicates += 1; results.push({ index: i, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 83383eb48..f63f9dea9 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -280,6 +280,43 @@ export const GET = apiHandler(async () => { return apiSuccess(result); }); +/** + * v1.28 — idempotent response for a re-mirrored medication. The create + * route returns the EXISTING row (status 200, vs 201 for a fresh create) + * when the caller re-posts a `(externalSource, externalId)` pair this + * user already mirrors — the iOS re-sync replays its full HealthKit + * medication list, and a replay must neither duplicate nor 500. Response + * shape mirrors the fresh-create envelope (schedules included, category + * joined, Decimal unwrapped); no audit row and no cache eviction because + * nothing was written. + */ +async function respondWithExistingMirror( + medication: Record & { id: string; unitsPerDose: unknown }, +): Promise { + let category = "OTHER"; + try { + const categories = await getMedicationCategories([medication.id]); + category = categories[medication.id] ?? "OTHER"; + } catch { + getEvent()?.addWarning("Medication categories could not be loaded"); + } + + annotate({ + action: { + name: "medication.create", + entity_type: "medication", + entity_id: medication.id, + }, + meta: { idempotent_replay: true }, + }); + + return apiSuccess({ + ...medication, + unitsPerDose: Number(medication.unitsPerDose), + category, + }); +} + export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -310,6 +347,8 @@ export const POST = apiHandler(async (request: NextRequest) => { criticalAlarmEnabled, atcCode, rxNormCode, + externalSource, + externalId, schedules, startsOn, endsOn, @@ -317,6 +356,23 @@ export const POST = apiHandler(async (request: NextRequest) => { asNeeded, } = parsed.data; + // v1.28 — idempotent mirror create. A mirrored medication (Apple + // Health) is keyed by the `(userId, externalSource, externalId)` + // unique; a re-post of the same concept id (iOS re-sync, retry after a + // dropped response) returns the EXISTING medication with 200 instead + // of minting a duplicate or 500-ing on P2002. The pre-query covers the + // common replay; the P2002 catch around the create below covers the + // concurrent race. + if (externalSource !== undefined && externalId !== undefined) { + const existing = await prisma.medication.findFirst({ + where: { userId: user.id, externalSource, externalId }, + include: { schedules: true }, + }); + if (existing) { + return respondWithExistingMirror(existing); + } + } + // ── v1.5 route invariants for the new scheduling primitives ─────── // // The Zod layer already enforces `rrule` xor `rollingIntervalDays` @@ -367,106 +423,138 @@ export const POST = apiHandler(async (request: NextRequest) => { const normalisedEndsOn = oneShot === true && startsOn ? startsOn : (endsOn ?? undefined); - const medication = await prisma.medication.create({ - data: { - userId: user.id, - name, - dose, - // v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the - // wire schema; Prisma fills the default GENERIC when omitted. - ...(treatmentClass !== undefined && { treatmentClass }), - ...(dosesPerUnit !== undefined && { dosesPerUnit }), - // v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted. - ...(unitsPerDose !== undefined && { unitsPerDose }), - // v1.17.0 — optional reorder lead override; null/omitted = inherit - // the user-level default in the low-stock alert. - ...(reorderLeadDays !== undefined && { reorderLeadDays }), - // v1.6.0 — route of administration; Prisma defaults to ORAL when omitted. - ...(deliveryForm !== undefined && { deliveryForm }), - // v1.8.5 — injection-site tracking opt-in + per-medication allowed - // sites; Prisma defaults to false / [] when omitted. - ...(trackInjectionSites !== undefined && { trackInjectionSites }), - ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), - // v1.7.0 — iOS reminder flags; Prisma defaults both to false. - ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), - ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), - // v1.9.0 — optional drug-classification codes (ATC / RxNorm). - // Validated for format by Zod; stored verbatim when present. - ...(atcCode !== undefined && { atcCode }), - ...(rxNormCode !== undefined && { rxNormCode }), - // v1.5 — wizard's reminders toggle now ships through the create - // payload (was orphaned in the initial diff). Prisma defaults to - // true when omitted, matching the legacy form's behaviour. - ...(notificationsEnabled !== undefined && { notificationsEnabled }), - // v1.5 scheduling primitives — pass-through when supplied. - ...(startsOn !== undefined && { startsOn }), - ...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }), - ...(oneShot !== undefined && { oneShot }), - // v1.16.11 — as-needed flag, field-by-field. An asNeeded create - // carries an empty `scheduleInputs`, so the nested create below - // persists zero schedule rows. - ...(asNeeded !== undefined && { asNeeded }), - schedules: { - create: scheduleInputs.map((s) => { - // Invariant 2 — default to FREQ=DAILY when nothing else is set. - // v1.7.0 — PRN schedules carry no cadence, so never default - // them to FREQ=DAILY (they would otherwise project + remind). - const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0; - const isPrn = s.scheduleType === "PRN"; - const defaultedRrule = - !oneShot && - !isPrn && - s.rrule === undefined && - s.rollingIntervalDays === undefined && - !hasLegacyDays - ? "FREQ=DAILY" - : s.rrule; + const createMedication = () => + prisma.medication.create({ + data: { + userId: user.id, + name, + dose, + // v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the + // wire schema; Prisma fills the default GENERIC when omitted. + ...(treatmentClass !== undefined && { treatmentClass }), + ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted. + ...(unitsPerDose !== undefined && { unitsPerDose }), + // v1.17.0 — optional reorder lead override; null/omitted = inherit + // the user-level default in the low-stock alert. + ...(reorderLeadDays !== undefined && { reorderLeadDays }), + // v1.6.0 — route of administration; Prisma defaults to ORAL when omitted. + ...(deliveryForm !== undefined && { deliveryForm }), + // v1.8.5 — injection-site tracking opt-in + per-medication allowed + // sites; Prisma defaults to false / [] when omitted. + ...(trackInjectionSites !== undefined && { trackInjectionSites }), + ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), + // v1.7.0 — iOS reminder flags; Prisma defaults both to false. + ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), + ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Validated for format by Zod; stored verbatim when present. + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), + // v1.28 — mirrored-medication provenance (Apple Health). Zod + // enforces the both-or-neither pairing; field-by-field like every + // other column. + ...(externalSource !== undefined && { externalSource }), + ...(externalId !== undefined && { externalId }), + // v1.5 — wizard's reminders toggle now ships through the create + // payload (was orphaned in the initial diff). Prisma defaults to + // true when omitted, matching the legacy form's behaviour. + ...(notificationsEnabled !== undefined && { notificationsEnabled }), + // v1.5 scheduling primitives — pass-through when supplied. + ...(startsOn !== undefined && { startsOn }), + ...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }), + ...(oneShot !== undefined && { oneShot }), + // v1.16.11 — as-needed flag, field-by-field. An asNeeded create + // carries an empty `scheduleInputs`, so the nested create below + // persists zero schedule rows. + ...(asNeeded !== undefined && { asNeeded }), + schedules: { + create: scheduleInputs.map((s) => { + // Invariant 2 — default to FREQ=DAILY when nothing else is set. + // v1.7.0 — PRN schedules carry no cadence, so never default + // them to FREQ=DAILY (they would otherwise project + remind). + const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0; + const isPrn = s.scheduleType === "PRN"; + const defaultedRrule = + !oneShot && + !isPrn && + s.rrule === undefined && + s.rollingIntervalDays === undefined && + !hasLegacyDays + ? "FREQ=DAILY" + : s.rrule; - // Invariant 3 — dual-write `timesOfDay` from windowStart when - // the legacy-shape iOS client doesn't send the new field. - const effectiveTimesOfDay = - s.timesOfDay && s.timesOfDay.length > 0 - ? s.timesOfDay - : [s.windowStart]; + // Invariant 3 — dual-write `timesOfDay` from windowStart when + // the legacy-shape iOS client doesn't send the new field. + const effectiveTimesOfDay = + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart]; - return { - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label ?? null, - dose: s.dose ?? null, - daysOfWeek: serializeScheduleRecurrence({ - daysOfWeek: s.daysOfWeek ?? [], - intervalWeeks: s.intervalWeeks ?? 1, - }), - // v1.5 first-class times-of-day. - timesOfDay: effectiveTimesOfDay, - ...(s.reminderGraceMinutes !== undefined && { - reminderGraceMinutes: s.reminderGraceMinutes, - }), - ...(defaultedRrule !== undefined && { rrule: defaultedRrule }), - ...(s.rollingIntervalDays !== undefined && { - rollingIntervalDays: s.rollingIntervalDays, - }), - // v1.7.0 — schedule type + cyclic weeks, field-by-field. - ...(s.scheduleType !== undefined && { - scheduleType: s.scheduleType, - }), - ...(s.cyclicOnWeeks !== undefined && { - cyclicOnWeeks: s.cyclicOnWeeks, - }), - ...(s.cyclicOffWeeks !== undefined && { - cyclicOffWeeks: s.cyclicOffWeeks, - }), - // v1.15.18 — per-dose configurable on-time windows. Stored as the - // validated `{ timeOfDay, start, end }[]` JSON; absent leaves the - // column NULL (every slot on the default ±1h derivation). - ...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }), - }; - }), + return { + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: s.daysOfWeek ?? [], + intervalWeeks: s.intervalWeeks ?? 1, + }), + // v1.5 first-class times-of-day. + timesOfDay: effectiveTimesOfDay, + ...(s.reminderGraceMinutes !== undefined && { + reminderGraceMinutes: s.reminderGraceMinutes, + }), + ...(defaultedRrule !== undefined && { rrule: defaultedRrule }), + ...(s.rollingIntervalDays !== undefined && { + rollingIntervalDays: s.rollingIntervalDays, + }), + // v1.7.0 — schedule type + cyclic weeks, field-by-field. + ...(s.scheduleType !== undefined && { + scheduleType: s.scheduleType, + }), + ...(s.cyclicOnWeeks !== undefined && { + cyclicOnWeeks: s.cyclicOnWeeks, + }), + ...(s.cyclicOffWeeks !== undefined && { + cyclicOffWeeks: s.cyclicOffWeeks, + }), + // v1.15.18 — per-dose configurable on-time windows. Stored as the + // validated `{ timeOfDay, start, end }[]` JSON; absent leaves the + // column NULL (every slot on the default ±1h derivation). + ...(s.doseWindows !== undefined && { + doseWindows: s.doseWindows, + }), + }; + }), + }, }, - }, - include: { schedules: true }, - }); + include: { schedules: true }, + }); + + let medication: Awaited>; + try { + medication = await createMedication(); + } catch (err: unknown) { + // v1.28 — P2002 on the mirror triple: a concurrent create of the + // same `(userId, externalSource, externalId)` won the race between + // the pre-query above and this insert. Resolve to the winning row + // and answer idempotently instead of 500-ing. Any other unique + // violation (there is none on this table today) rethrows. + const isP2002 = + typeof err === "object" && + err !== null && + "code" in err && + (err as { code: string }).code === "P2002"; + if (isP2002 && externalSource !== undefined && externalId !== undefined) { + const winner = await prisma.medication.findFirst({ + where: { userId: user.id, externalSource, externalId }, + include: { schedules: true }, + }); + if (winner) return respondWithExistingMirror(winner); + } + throw err; + } const normalizedCategory = await setMedicationCategory( medication.id, diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 61e734eab..980498c04 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -55,6 +55,7 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; +import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker"; import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; // v1.4.40 W-RSC — module-scope so the option object is stable across @@ -221,6 +222,10 @@ export default function DashboardPageClient() { return { summaries: snap.tiles.summaries, lastSeenByType: snap.tiles.lastSeenByType, + // v1.28.x — latest-night source-discrepancy annotation for the + // sleep tile's discreet "sources disagree" marker. Optional on + // the snapshot (additive contract), so `?? null` here. + sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null, bpInTargetPct: snap.extras?.bpInTargetPct ?? null, bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null, bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null, @@ -250,6 +255,7 @@ export default function DashboardPageClient() { return { summaries: merged.summaries, lastSeenByType: merged.lastSeenByType, + sleepSourceDiscrepancy: merged.sleepSourceDiscrepancy, bpInTargetPct: merged.bpInTargetPct, bpInTargetPct7d: merged.bpInTargetPct7d, bpInTargetPct30d: merged.bpInTargetPct30d, @@ -978,6 +984,18 @@ export default function DashboardPageClient() { compareBaseline={compareBaseline} compareDelta={tileCompareDelta(sleepSummaryHours)} staleDays={tileStaleDays("SLEEP_DURATION")} + // v1.28.x — discreet "sources disagree" marker next to the + // headline when two writers reported clearly different + // totals for the latest night (server-computed, + // observational; same marker as the hypnogram). Null on + // the ordinary path → no layout change. + valueAdornment={ + data?.sleepSourceDiscrepancy ? ( + + ) : null + } /> ), }); diff --git a/src/components/charts/__tests__/trend-card.test.tsx b/src/components/charts/__tests__/trend-card.test.tsx index 316055747..ea6405069 100644 --- a/src/components/charts/__tests__/trend-card.test.tsx +++ b/src/components/charts/__tests__/trend-card.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { Activity } from "lucide-react"; import { TrendCard } from "../trend-card"; +import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker"; import { I18nProvider } from "@/lib/i18n/context"; /** @@ -210,6 +211,52 @@ describe(" value is never truncated", () => { }); }); +describe(" valueAdornment slot", () => { + // v1.28.x — the dashboard sleep tile passes the discreet source- + // discrepancy marker into the value row when the server flagged two + // writers with clearly different totals for the latest night. + const baseProps = { + label: "Sleep", + latest: 10.25, + unit: "h", + avg7: 7.5, + avg30: 7.4, + slope30: null, + icon: Activity, + }; + + it("renders the sleep source-discrepancy marker next to the value when present", () => { + const html = render( + + } + />, + ); + expect(html).toContain('data-slot="trend-card-value-adornment"'); + expect(html).toContain('data-slot="sleep-source-discrepancy"'); + }); + + it("renders no adornment slot when the annotation is absent", () => { + const html = render(); + expect(html).not.toContain('data-slot="trend-card-value-adornment"'); + expect(html).not.toContain('data-slot="sleep-source-discrepancy"'); + }); +}); + describe(" responsive layout", () => { it("keeps long BP target tile content wrappable inside the card", () => { // v1.4.28 FB-C2 — the BD-Zielbereich tile dropped `avgAllTime` diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index f654a841b..cd5c3cbca 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -99,6 +99,14 @@ interface TrendCardProps { * height. */ emptyHint?: string | null; + /** + * v1.28.x — optional adornment rendered directly after the unit in the + * value row (before the trend-arrow slot). The dashboard sleep tile + * passes the discreet source-discrepancy marker here so the hint sits + * next to the number it annotates. Absent → the row renders exactly as + * before (no reserved space, no layout shift). + */ + valueAdornment?: React.ReactNode; } export function TrendCard({ @@ -120,6 +128,7 @@ export function TrendCard({ compareDelta = null, staleDays = null, emptyHint = null, + valueAdornment = null, }: TrendCardProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -274,6 +283,18 @@ export function TrendCard({ {unit} + {/* v1.28.x — optional inline adornment (e.g. the sleep tile's + source-discrepancy marker). `self-center` keeps the icon + vertically centred in the row without disturbing the + baseline alignment of the value + unit pair. */} + {valueAdornment != null ? ( + + {valueAdornment} + + ) : null} )} 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`; - } - return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; -} - -/** - * Localised label keys per `MeasurementSource` — the same map the - * source-priority settings surface uses (`sources-section.tsx`), so both - * surfaces humanise a source identically. - */ -const SOURCE_LABEL_KEYS: Record = { - WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS", - APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH", - MANUAL: "settings.sections.sources.sourceLabels.MANUAL", - IMPORT: "settings.sections.sources.sourceLabels.IMPORT", - WHOOP: "settings.sections.sources.sourceLabels.WHOOP", - COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED", - FITBIT: "settings.sections.sources.sourceLabels.FITBIT", -}; - -/** - * Brand names for sources without a localised settings label — brand - * spellings are locale-invariant, so no i18n key is needed for them. - */ -const SOURCE_BRAND_NAMES: Record = { - OURA: "Oura", - POLAR: "Polar", - GOOGLE_HEALTH: "Google Health", - NIGHTSCOUT: "Nightscout", - STRAVA: "Strava", - TELEGRAM: "Telegram", - MCP: "MCP", -}; - -/** - * Localised device-type label keys (`Measurement.deviceType`), reused from - * the source-priority settings surface. Appended in parentheses so two - * writer apps behind the same source (watch vs phone under Apple Health) - * stay distinguishable in the discrepancy tooltip. - */ -const DEVICE_TYPE_LABEL_KEYS: Record = { - watch: "settings.sections.sources.deviceLabels.watch", - band: "settings.sections.sources.deviceLabels.band", - ring: "settings.sections.sources.deviceLabels.ring", - phone: "settings.sections.sources.deviceLabels.phone", - scale: "settings.sections.sources.deviceLabels.scale", - other: "settings.sections.sources.deviceLabels.other", -}; - -function sourceDisplayName( - source: string, - deviceType: string | null, - t: (key: string) => string, -): string { - const labelKey = SOURCE_LABEL_KEYS[source]; - const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source); - const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined; - return deviceKey ? `${base} (${t(deviceKey)})` : base; -} +// v1.28.x — `formatMinutes`, the source/device label maps, and the +// discrepancy marker itself moved to `sleep-source-discrepancy-marker.tsx` +// so the dashboard sleep tile renders the identical hint. export function SleepHypnogram({ session }: SleepHypnogramProps) { const { t, locale } = useTranslations(); @@ -335,44 +265,11 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { {/* Discreet source-discrepancy marker: two writers reported clearly different totals for this night. Observational — the headline stays the winning writer's number; the tooltip lists - each source's own total. Muted, no colour wash, no banner; - negative margins keep the 44 px hit target from growing the - caption line (same trick as measurement-diversity-nudge). */} - {session.sourceDiscrepancy ? ( - - - - - - -

- {t("insights.sleep.sourceDiscrepancy.tooltipTitle")} -

-

- {session.sourceDiscrepancy.sources - .map( - (b) => - `${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`, - ) - .join(" · ")} -

-
-
-
- ) : null} + each source's own total. Shared with the dashboard sleep + tile via `sleep-source-discrepancy-marker.tsx`. */} +
{/* The night's measuring source rides the header as a muted caption — it is already on the wire, and "which device diff --git a/src/components/insights/sleep-source-discrepancy-marker.tsx b/src/components/insights/sleep-source-discrepancy-marker.tsx new file mode 100644 index 000000000..9640a8d77 --- /dev/null +++ b/src/components/insights/sleep-source-discrepancy-marker.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { TriangleAlert } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.28.x — the discreet "sources disagree" marker, extracted from + * `sleep-hypnogram.tsx` (which introduced it in v1.28.21) so the dashboard + * sleep tile and the hypnogram render the identical hint off the identical + * wire shape. Observational only: the headline number always stays the + * winning writer's total; the tooltip lists each writer's own total so a + * multi-tracker user can see WHY the night reads surprising. + */ + +/** Wire shape of the server-computed annotation (rounded minutes). */ +export interface SleepSourceDiscrepancyDto { + deltaMinutes: number; + sources: { + source: string; + deviceType: string | null; + asleepMinutes: number; + }[]; +} + +export function formatMinutes(total: number, locale: string): string { + const hours = Math.floor(total / 60); + const mins = Math.round(total - hours * 60); + if (locale === "de") { + return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`; + } + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; +} + +/** + * Localised label keys per `MeasurementSource` — the same map the + * source-priority settings surface uses (`sources-section.tsx`), so both + * surfaces humanise a source identically. + */ +const SOURCE_LABEL_KEYS: Record = { + WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS", + APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH", + MANUAL: "settings.sections.sources.sourceLabels.MANUAL", + IMPORT: "settings.sections.sources.sourceLabels.IMPORT", + WHOOP: "settings.sections.sources.sourceLabels.WHOOP", + COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED", + FITBIT: "settings.sections.sources.sourceLabels.FITBIT", +}; + +/** + * Brand names for sources without a localised settings label — brand + * spellings are locale-invariant, so no i18n key is needed for them. + */ +const SOURCE_BRAND_NAMES: Record = { + OURA: "Oura", + POLAR: "Polar", + GOOGLE_HEALTH: "Google Health", + NIGHTSCOUT: "Nightscout", + STRAVA: "Strava", + TELEGRAM: "Telegram", + MCP: "MCP", +}; + +/** + * Localised device-type label keys (`Measurement.deviceType`), reused from + * the source-priority settings surface. Appended in parentheses so two + * writer apps behind the same source (watch vs phone under Apple Health) + * stay distinguishable in the discrepancy tooltip. + */ +const DEVICE_TYPE_LABEL_KEYS: Record = { + watch: "settings.sections.sources.deviceLabels.watch", + band: "settings.sections.sources.deviceLabels.band", + ring: "settings.sections.sources.deviceLabels.ring", + phone: "settings.sections.sources.deviceLabels.phone", + scale: "settings.sections.sources.deviceLabels.scale", + other: "settings.sections.sources.deviceLabels.other", +}; + +export function sourceDisplayName( + source: string, + deviceType: string | null, + t: (key: string) => string, +): string { + const labelKey = SOURCE_LABEL_KEYS[source]; + const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source); + const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined; + return deviceKey ? `${base} (${t(deviceKey)})` : base; +} + +export interface SleepSourceDiscrepancyMarkerProps { + discrepancy: SleepSourceDiscrepancyDto | null | undefined; + /** Tooltip alignment relative to the trigger (default `"start"`). */ + align?: "start" | "center" | "end"; +} + +/** + * Discreet source-discrepancy marker: two writers reported clearly + * different totals for the night. Muted, no colour wash, no banner; + * negative margins keep the 44 px hit target from growing the host line + * (same trick as measurement-diversity-nudge). Renders nothing when the + * annotation is absent — no layout shift on the ordinary path. + */ +export function SleepSourceDiscrepancyMarker({ + discrepancy, + align = "start", +}: SleepSourceDiscrepancyMarkerProps) { + const { t, locale } = useTranslations(); + if (!discrepancy) return null; + return ( + + + + + + +

+ {t("insights.sleep.sourceDiscrepancy.tooltipTitle")} +

+

+ {discrepancy.sources + .map( + (b) => + `${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`, + ) + .join(" · ")} +

+
+
+
+ ); +} diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index b5fddee60..7cb83ecb9 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -1188,3 +1188,67 @@ describe("source discrepancy annotation", () => { }); }); }); + +describe("night-level source discrepancy (dashboard path)", () => { + // Same fixture as the session-level suite: granular Apple (455 asleep) + // next to a bare Withings aggregate claiming 615 for the same overnight + // session. + const discrepantOvernight: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + srcRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH"), + srcRow("2026-06-04T05:30:00.000Z", "ASLEEP", 615, "WITHINGS"), + ]; + + it("carries the main session's annotation on the night without changing the served total", () => { + const nights = reconstructSleepNights(discrepantOvernight, "UTC"); + expect(nights).toHaveLength(1); + // The served total stays the winning writer's number — the annotation + // observes, the writer collapse decides. + expect(nights[0].asleepMinutes).toBe(455); + expect(nights[0].sourceDiscrepancy).toEqual({ + deltaMinutes: 160, + sources: [ + { source: "WITHINGS", deviceType: null, asleepMinutes: 615 }, + { source: "APPLE_HEALTH", deviceType: null, asleepMinutes: 455 }, + ], + }); + // The dashboard reads the night through `summarizeSleepNights`. + const { latestNight } = summarizeSleepNights(discrepantOvernight, "UTC"); + expect(latestNight?.asleepMinutes).toBe(455); + expect(latestNight?.sourceDiscrepancy).toEqual(nights[0].sourceDiscrepancy); + }); + + it("ignores a nap-only disagreement — sessions never cross-compare", () => { + // Clean single-writer overnight (455 asleep), then a same-wake-day nap + // (> 3 h gap) where two writers clearly disagree (120 vs 200 → delta 80 + // > 45 min and > 20 % of 200). The nap's session-level annotation must + // NOT leak onto the night: the night's marker reflects the MAIN + // (overnight) session only. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + srcRow("2026-06-04T05:00:00.000Z", "REM", 65, "APPLE_HEALTH"), + srcRow("2026-06-04T15:00:00.000Z", "CORE", 120, "APPLE_HEALTH"), + srcRow("2026-06-04T15:00:00.000Z", "ASLEEP", 200, "WITHINGS"), + ]; + // Sanity: the nap session itself IS flagged at session level. + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(2); + expect(sessions[1].sourceDiscrepancy).not.toBeNull(); + + const { latestNight } = summarizeSleepNights(rows, "UTC"); + expect(latestNight?.night).toBe("2026-06-04"); + // Overnight (455) outweighs the nap (120) → main session is clean. + expect(latestNight?.sourceDiscrepancy).toBeNull(); + }); + + it("stays null for a single-writer night", () => { + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T00:30:00.000Z", "CORE", 300, "APPLE_HEALTH"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "APPLE_HEALTH"), + ]; + const { latestNight } = summarizeSleepNights(rows, "UTC"); + expect(latestNight?.sourceDiscrepancy).toBeNull(); + }); +}); diff --git a/src/lib/analytics/merge-slim-thick.ts b/src/lib/analytics/merge-slim-thick.ts index b9cc72c41..a529f27c5 100644 --- a/src/lib/analytics/merge-slim-thick.ts +++ b/src/lib/analytics/merge-slim-thick.ts @@ -30,6 +30,7 @@ * have caught the empty-object short-circuit bug. */ import type { DataSummary } from "@/lib/analytics/trends"; +import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; export interface AnalyticsSlimLike { summaries?: Record | null; @@ -37,6 +38,12 @@ export interface AnalyticsSlimLike { string, { lastSeenAt: string; daysAgo: number } | null > | null; + /** + * v1.28.x — latest-night source-discrepancy annotation, carried by + * both slices (the slim post-pass computes it; the thick body passes + * it through). Additive and null-safe. + */ + sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null; } export interface AnalyticsThickLike extends AnalyticsSlimLike { @@ -64,6 +71,7 @@ export interface MergedDashboardAnalytics { bpInTargetCount90: number | null; bpInTargetSpanDays90: number | null; glucoseByContext: Record | undefined; + sleepSourceDiscrepancy: SleepSourceDiscrepancy | null; } function hasContent( @@ -117,5 +125,13 @@ export function mergeSlimAndThickAnalytics( bpInTargetCount90: thick?.bpInTargetCount90 ?? null, bpInTargetSpanDays90: thick?.bpInTargetSpanDays90 ?? null, glucoseByContext: thick?.glucoseByContext ?? undefined, + // Rides the same discriminator as `summaries`: whichever slice + // supplied the summaries also supplies the sleep annotation, so the + // marker can never point at a night the rendered headline doesn't + // show. `?? null` tolerates older cached payloads without the field. + sleepSourceDiscrepancy: + (slimSummariesHaveContent + ? slim!.sleepSourceDiscrepancy + : thick?.sleepSourceDiscrepancy) ?? null, }; } diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index fe050825e..d5b530aea 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -276,6 +276,17 @@ export interface SleepNight { awakeMinutes: number | null; /** Per-stage minutes for the night (only stages the winning writer reported). */ stages: Partial>; + /** + * Non-null when two writer buckets reported clearly different asleep + * totals for the night's MAIN session — the same-wake-day session with + * the most asleep minutes, normally the overnight block (see + * `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's + * writers are not measured against the overnight's. Observational only — + * the served `asleepMinutes` stays the winning writer's total; the + * annotation lets the UI mark the number with a discreet + * "sources disagree" hint. + */ + sourceDiscrepancy: SleepSourceDiscrepancy | null; } /** Sentinel for rows that carry no source — collapses to one bucket. */ @@ -604,6 +615,15 @@ export function reconstructSleepNights( // edits. let latest = nightSessions[0].pool[0].measuredAt; const stages: Partial> = {}; + // MAIN session of the night = most asleep minutes (same per-session + // asleep total the headline sums; tie → later wake instant, matching + // `pickMainNightAndNaps`). Its FULL raw row set (every writer, + // pre-collapse) feeds the night's source-discrepancy annotation — + // per session, so a nap's writers never cross-compare against the + // overnight block's. + let mainSessionRows: SleepStageRow[] | null = null; + let mainSessionAsleep = 0; + let mainSessionEnd = Number.NEGATIVE_INFINITY; for (const { pool: sessionRows, all } of nightSessions) { // In-bed: union envelope across ALL writers in the session (sessions // are > 3 h apart, so per-session envelopes never overlap and summing @@ -620,10 +640,14 @@ export function reconstructSleepNights( const sawGranular = sawGranularStage(sessionRows); // Asleep total: sum per session so a granular overnight + a bare-only // nap both contribute (the merged-night nap under-count fix). - asleep += asleepMinutesOf(sessionRows); + const sessionAsleep = asleepMinutesOf(sessionRows); + asleep += sessionAsleep; + let sessionEnd = Number.NEGATIVE_INFINITY; for (const r of sessionRows) { const minutes = Number.isFinite(r.value) ? r.value : 0; if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt; + if (r.measuredAt.getTime() > sessionEnd) + sessionEnd = r.measuredAt.getTime(); const stage = r.sleepStage; // Drop the redundant bare ASLEEP aggregate (and stage-less rows) from // the per-stage breakdown when this session has the granular partition, @@ -637,6 +661,15 @@ export function reconstructSleepNights( sawAwake = true; } } + if ( + sessionAsleep > 0 && + (sessionAsleep > mainSessionAsleep || + (sessionAsleep === mainSessionAsleep && sessionEnd > mainSessionEnd)) + ) { + mainSessionAsleep = sessionAsleep; + mainSessionEnd = sessionEnd; + mainSessionRows = all; + } } nights.push({ night, @@ -645,6 +678,9 @@ export function reconstructSleepNights( inBedMinutes: sawInBed ? inBed : null, awakeMinutes: sawAwake ? awake : null, stages, + sourceDiscrepancy: mainSessionRows + ? sessionSourceDiscrepancy(mainSessionRows) + : null, }); } return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index fe43a1ad2..f476826ab 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -82,6 +82,7 @@ import { import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; import { summarizeSleepNights, + type SleepSourceDiscrepancy, type SleepStageRow, } from "@/lib/analytics/sleep-night"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -247,6 +248,17 @@ export interface SummariesSlice { string, { lastSeenAt: string; daysAgo: number } | null >; + /** + * v1.28.x — source-discrepancy annotation for the LATEST night's main + * session (see `SleepNight.sourceDiscrepancy`): non-null when two writer + * buckets reported clearly different asleep totals for the night behind + * `summaries.SLEEP_DURATION.latest`. Observational only — the served + * summary stays the winning writer's totals; the dashboard sleep tile + * marks the number with a discreet "sources disagree" hint. Minutes are + * whole (rounded), matching the `/api/sleep/night` serialisation. + * Additive on the wire; every consumer must tolerate `null`. + */ + sleepSourceDiscrepancy: SleepSourceDiscrepancy | null; } export async function computeSummariesSlice( @@ -362,12 +374,25 @@ async function withSleepNightTotals( resolveUserTimezone(userId), loadUserSourcePriority(userId), ]); - const { summary } = summarizeSleepNights(rows, tz, priorityJson); + const { summary, latestNight } = summarizeSleepNights(rows, tz, priorityJson); // Preserve the slope tuples the night summary doesn't compute (the // dashboard tile reads slope30); recompute them off the night series is // out of scope here — the per-night `summarize()` already fills slope7 / // slope30 / slope90 from the night DataPoints, so use them directly. slice.summaries.SLEEP_DURATION = summary; + // Additive, observational: the latest night's main session saw two + // writer buckets with clearly different asleep totals. Round to whole + // minutes, matching the `/api/sleep/night` serialisation. + slice.sleepSourceDiscrepancy = latestNight?.sourceDiscrepancy + ? { + deltaMinutes: Math.round(latestNight.sourceDiscrepancy.deltaMinutes), + sources: latestNight.sourceDiscrepancy.sources.map((b) => ({ + source: b.source, + deviceType: b.deviceType, + asleepMinutes: Math.round(b.asleepMinutes), + })), + } + : null; return slice; } @@ -693,7 +718,7 @@ async function computeFromRollups(userId: string): Promise { }, }); - return { summaries, bmi: null, lastSeenByType }; + return { summaries, bmi: null, lastSeenByType, sleepSourceDiscrepancy: null }; } /** @@ -938,7 +963,7 @@ async function computeFromLiveAggregate( }, }); - return { summaries, bmi: null, lastSeenByType }; + return { summaries, bmi: null, lastSeenByType, sleepSourceDiscrepancy: null }; } /** diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 5840676fa..57413855d 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -36,6 +36,7 @@ */ import type { PrismaClient, MeasurementType } from "@/generated/prisma/client"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; +import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; import { getUnitForType } from "@/lib/validations/measurement"; import { summarize, @@ -384,6 +385,13 @@ export interface DashboardSnapshot { summary: DataSummary | null; entries: DashboardSnapshotMoodEntry[]; }; + /** + * v1.28.x — source-discrepancy annotation for the latest night behind + * `summaries.SLEEP_DURATION.latest` (see `SummariesSlice`). Null when + * the writers agree or the sleep module is off. Optional on the type + * (additive contract) so older cached snapshots / fixtures stay valid. + */ + sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null; }; /** Thick phase — null on a rollup-coverage miss. */ extras: DashboardSnapshotExtras | null; @@ -1233,6 +1241,10 @@ export async function buildDashboardSnapshot( summary: mood.entries.length > 0 ? mood.summary : null, entries: mood.entries, }, + // v1.28.x — gated like the SLEEP_DURATION summary itself: a + // disabled sleep module carries no sleep annotation either. + sleepSourceDiscrepancy: + modules.sleep === false ? null : slimRaw.sleepSourceDiscrepancy, }, extras: extrasResult?.extras ?? null, medsToday, diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index edefcee66..906e70458 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -442,7 +442,9 @@ export function collapseMeasurementsToCanonical( ? null : metricKeyForType(type as MeasurementType); if (!metricKey) { - out.push(...rows); + // Loop, not `push(...rows)` — a sample-dense type's window rows run to + // six figures and a spread call overflows the stack (v1.28.22 class). + for (const r of rows) out.push(r); continue; } const { canonicalRows } = pickCanonicalSourceRows( @@ -457,11 +459,28 @@ export function collapseMeasurementsToCanonical( // `pickCanonicalSourceRows` returns the spread copies; re-key back to the // original rows by reference identity is unnecessary because the spread // preserves every field the caller reads (value / measuredAt / type). - out.push(...(canonicalRows as unknown as T[])); + for (const r of canonicalRows as unknown as T[]) out.push(r); } return out; } +/** + * Loop-based min/max over a value array. NEVER `Math.min(...values)` here: the + * report window's per-type rows are raw measurements, and a sample-dense type + * (per-sample heart rate, CGM glucose) runs to six figures over a year — a + * spread call overflows the stack exactly on the heaviest accounts (the same + * failure class as the Google Health fullSync tracker, fixed v1.28.22). + */ +function minMaxOf(values: readonly number[]): { min: number; max: number } { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const v of values) { + if (v < min) min = v; + if (v > max) max = v; + } + return { min, max }; +} + /** * Summarise RECOVERY_SCORE over the window from the CANONICAL row per day: * a WHOOP-native row wins over the COMPUTED proxy for the same day, so the @@ -491,8 +510,8 @@ export function summariseCanonicalRecovery( type: "RECOVERY_SCORE", latest: Math.round(latestRow.value), avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length), - min: Math.round(Math.min(...values)), - max: Math.round(Math.max(...values)), + min: Math.round(minMaxOf(values).min), + max: Math.round(minMaxOf(values).max), count: values.length, latestAt: latestRow.measuredAt.toISOString(), }; @@ -1025,8 +1044,7 @@ export async function collectDoctorReportData( const values = entries.map((e) => e.value); stats[type] = { avg: values.reduce((a, b) => a + b, 0) / values.length, - min: Math.min(...values), - max: Math.max(...values), + ...minMaxOf(values), count: values.length, latest: values[values.length - 1], }; @@ -1164,8 +1182,8 @@ export async function collectDoctorReportData( moodScores.length > 0 ? { avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length, - min: Math.min(...moodScores), - max: Math.max(...moodScores), + min: minMaxOf(moodScores).min, + max: minMaxOf(moodScores).max, count: moodScores.length, distribution: { 1: moodScores.filter((s) => s === 1).length, @@ -1207,8 +1225,7 @@ export async function collectDoctorReportData( const values = rows.map((r) => r.value); glucoseStats[ctx] = { avg: values.reduce((a, b) => a + b, 0) / values.length, - min: Math.min(...values), - max: Math.max(...values), + ...minMaxOf(values), count: values.length, latest: values[values.length - 1], }; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 208f28051..2f76a1905 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -401,9 +401,14 @@ function drawSparkline( doc.setTextColor(30, 30, 30); doc.text(label, x, y + labelHeight - 1.5); - const values = points.map((p) => p.value); - const min = Math.min(...values); - const max = Math.max(...values); + // Loop, not `Math.min(...values)` — chart points can be sample-grain on + // dense accounts and a spread call overflows the stack (v1.28.22 class). + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const p of points) { + if (p.value < min) min = p.value; + if (p.value > max) max = p.value; + } const range = max - min || 1; const chartTop = y + labelHeight; diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 4bc18c085..28b314873 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -57,9 +57,10 @@ export interface ApplyCanonicalSlotWriteInput { * Source to stamp on a freshly-created slot row. `REMINDER` is the * Telegram-webhook path — its take / skip confirmations converge onto * the worker-minted pending row like every other write, and a fresh - * create keeps the historical reminder provenance. + * create keeps the historical reminder provenance. `APPLE_HEALTH` + * (v1.28) is the bulk route's HealthKit dose-event import. */ - createSource: "WEB" | "API" | "REMINDER"; + createSource: "WEB" | "API" | "REMINDER" | "APPLE_HEALTH"; /** * v1.8.5 — resolved + server-validated injection site to persist on a * taken write. `null` = no site (the column stays / is set NULL). Only @@ -84,6 +85,16 @@ export interface ApplyCanonicalSlotWriteInput { * a recorded dose away. */ doseTaken?: string | null; + /** + * v1.28 — external dose-event identifier (HealthKit + * `HKMedicationDoseEvent` UUID) to persist on the row. `null` / omitted + * = no external id (the column stays untouched on an update, NULL on a + * create). Like `injectionSite` / `doseTaken`, a write that carries no + * id never clears a previously-recorded one. The bulk route dedups the + * batch against the live `(userId, externalId)` unique before the + * upsert, so a carried id is trusted fresh here. + */ + externalId?: string | null; } export interface ApplyCanonicalSlotWriteResult { @@ -228,6 +239,7 @@ export async function applyCanonicalSlotWrite( injectionSite = null, attributionSource, doseTaken = null, + externalId = null, } = input; const rows = await findSlotRows(client, userId, medicationId, canonicalSlot); @@ -260,6 +272,8 @@ export async function applyCanonicalSlotWrite( ...(attributionSource !== undefined && { attributionSource }), // v1.16.4 — dose override only when the write carries one. ...(doseTaken !== null && { doseTaken }), + // v1.28 — external dose-event id only when the write carries one. + ...(externalId !== null && { externalId }), }, select: SLOT_ROW_SELECT, })) as SlotIntakeRow; @@ -307,6 +321,7 @@ async function applyToExisting( injectionSite = null, attributionSource, doseTaken = null, + externalId = null, } = input; const existingActioned = existing.takenAt !== null || existing.skipped; @@ -357,6 +372,10 @@ async function applyToExisting( // v1.16.4 — dose override only when this write carries one; never // clear a previously-recorded override with a null re-post. ...(doseTaken !== null && { doseTaken }), + // v1.28 — external dose-event id only when this write carries one + // (an Apple dose converging onto a pre-minted pending slot row); + // never clears a previously-recorded id. + ...(externalId !== null && { externalId }), }, select: SLOT_ROW_SELECT, })) as SlotIntakeRow; diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 8a4a033a9..4eb23fe14 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -850,6 +850,25 @@ export const dashboardSnapshotResponse = z }), ), }), + // v1.28.x — additive: source-discrepancy annotation for the latest + // night behind `summaries.SLEEP_DURATION.latest`. Same shape as the + // per-session `sourceDiscrepancy` on the sleep-night resource. + sleepSourceDiscrepancy: z + .object({ + deltaMinutes: z.number().int().nonnegative(), + sources: z.array( + z.object({ + source: z.string(), + deviceType: z.string().nullable(), + asleepMinutes: z.number().int().nonnegative(), + }), + ), + }) + .nullable() + .optional() + .describe( + "Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep tile's headline. Null when the writers agree or the sleep module is off; optional for older cached snapshots.", + ), }), extras: z .object({ diff --git a/src/lib/validations/medication/create-update.ts b/src/lib/validations/medication/create-update.ts index 02373b3d4..053edcb8e 100644 --- a/src/lib/validations/medication/create-update.ts +++ b/src/lib/validations/medication/create-update.ts @@ -162,6 +162,33 @@ export const createMedicationSchema = z atcCode: atcCodeField, /** v1.9.0 — optional RxNorm RxCUI (secondary, US). */ rxNormCode: rxNormCodeField, + /** + * v1.28 — mirrored-medication provenance. Only APPLE_HEALTH is + * accepted here (the iOS 26+ HealthKit Medications sync); the other + * IntakeSource values stay server-owned. Both-or-neither with + * `externalId` (refine below). + */ + externalSource: z + .literal("APPLE_HEALTH") + .optional() + .describe( + "Marks the medication as MIRRORED from an external medication list. Only `APPLE_HEALTH` (the iOS 26+ HealthKit Medications sync) is accepted. Must be supplied together with `externalId`. A mirrored medication is source-exclusive: dose events from that source attach only to it, and the app's own dose-logging UI stays off it.", + ), + /** + * v1.28 — the source's stable concept identifier (HealthKit + * `medicationConceptIdentifier`). Re-posting the same + * `(externalSource, externalId)` pair returns the existing + * medication (200) instead of creating a duplicate. + */ + externalId: z + .string() + .trim() + .min(1) + .max(128) + .optional() + .describe( + "Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max 128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns the existing medication with status 200 instead of minting a duplicate.", + ), ...courseWindowFields, /** * v1.16.11 — optional at the type level so an `asNeeded: true` @@ -170,6 +197,15 @@ export const createMedicationSchema = z */ schedules: z.array(scheduleSchema).optional(), }) + .refine( + // v1.28 — mirrored-medication provenance is a pair: a source without + // its concept id (or vice versa) can neither dedup nor mirror. + (b) => (b.externalSource === undefined) === (b.externalId === undefined), + { + message: "externalSource and externalId must be supplied together", + path: ["externalId"], + }, + ) .refine((b) => b.oneShot !== true || !!b.startsOn, { message: "startsOn is required when oneShot is true", path: ["startsOn"], @@ -203,7 +239,7 @@ export const createMedicationSchema = z .meta({ id: "CreateMedicationRequest", description: - 'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry.', + 'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry. v1.28 — `externalSource` + `externalId` (both-or-neither) mark the medication as mirrored from Apple Health; re-posting the same pair is idempotent and returns the existing medication with status 200.', }); export const updateMedicationSchema = z diff --git a/src/types/analytics.ts b/src/types/analytics.ts index 7c6efc965..529a55334 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -34,6 +34,7 @@ * shape loses it. */ import type { DataSummary } from "@/lib/analytics/trends"; +import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; import type { CorrelationResult } from "@/lib/insights/correlations"; /** Shape consumed by every `/insights//page.tsx`. */ @@ -107,6 +108,17 @@ export interface DashboardAnalyticsData { string, { lastSeenAt: string; daysAgo: number } | null >; + /** + * v1.28.x — source-discrepancy annotation for the latest night behind + * `summaries.SLEEP_DURATION.latest` (carried by both the slim + * `?slice=summaries` branch and the thick slice; the dashboard snapshot + * mirrors it under `tiles.sleepSourceDiscrepancy`). Non-null when two + * writer buckets reported clearly different asleep totals for that + * night's main session; the sleep tile marks the headline with a + * discreet "sources disagree" hint. Additive — absent/null keeps the + * tile rendering exactly as before. + */ + sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null; } /**