From 03b24461ad937531c5bc4bf31571fcd4e0507aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:33:30 +0200 Subject: [PATCH] feat(healthkit): record Apple Health dose arrivals and the last background arrival per type Migration 0401 turns healthkit_type_syncs.type into text so the ledger can hold MEDICATION_DOSE, and adds last_background_at (seeded from rows whose latest arrival was a background one). POST /api/medications/intake/bulk takes syncTrigger and records APPLE_HEALTH dose arrivals; the Apple Health status read serves a MEDICATION_DOSE freshness entry and lastBackgroundAt on every entry with arrival facts. --- docs/api/openapi.yaml | 42 +++++++-- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- .../migration.sql | 23 +++++ prisma/schema.prisma | 19 ++-- .../healthkit/__tests__/route.test.ts | 86 +++++++++++++++++-- src/app/api/integrations/healthkit/route.ts | 27 ++++-- src/app/api/measurements/batch/route.ts | 3 +- src/app/api/medications/intake/bulk/route.ts | 37 +++++++- .../integrations/apple-health-card.tsx | 15 +++- .../integrations/metric-freshness-list.tsx | 4 + src/lib/integrations/healthkit-type-sync.ts | 61 +++++++++---- src/lib/integrations/metric-freshness.ts | 29 +++++++ src/lib/integrations/sync-verdict.ts | 13 ++- src/lib/openapi/routes/integrations.ts | 11 ++- src/lib/openapi/routes/measurements.ts | 2 +- src/lib/openapi/routes/medications/paths.ts | 6 ++ 22 files changed, 352 insertions(+), 54 deletions(-) create mode 100644 prisma/migrations/0401_healthkit_type_sync_text_and_background/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 979a0b410..d1b6477b7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -25592,8 +25592,9 @@ components: account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on - each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is - stored. Optional and backward-compatible: pre-#66 clients omit it.' + each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` + trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and + backward-compatible: pre-#66 clients omit it.' type: string enum: - foreground @@ -26181,6 +26182,18 @@ components: type: array items: $ref: "#/components/schemas/BulkMedicationIntakeEntry" + syncTrigger: + description: 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client + for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` + or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET + /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. + It changes no dose row. Optional; omitted records a null trigger.' + type: string + enum: + - foreground + - background + - push + - manual required: - entries description: iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry @@ -73668,7 +73681,9 @@ components: properties: type: type: string - description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg. + description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, + since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or + skip a type they do not know rather than fail. lastSeenAt: type: string format: date-time @@ -73709,13 +73724,28 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + lastBackgroundAt: + description: 'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later + `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so + opening the app does not hide an earlier background arrival. Null when the type has never arrived in the + background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose + latest arrival then was a background one starts with that date and every other type starts null. Same + omission rules as `lastReceivedAt`.' + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" required: - type - lastSeenAt - stale additionalProperties: false description: "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type - arrival facts recorded by `POST /api/measurements/batch` (v1.42)." + arrival facts recorded by `POST /api/measurements/batch` (v1.42). Since v1.42.4 the list can also hold a + `MEDICATION_DOSE` entry: `lastSeenAt` is the newest live dose imported from Apple Health (its `takenAt`, or its + slot for a skipped dose), and the arrival facts come from `POST /api/medications/intake/bulk` entries sent with + `source: \"APPLE_HEALTH\"`. A dose recorded in the app does not move them. Doses are never flagged `stale`." AppleHealthSyncProgress: type: object properties: @@ -75840,7 +75870,9 @@ components: properties: type: type: string - description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg. + description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, + since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or + skip a type they do not know rather than fail. lastSeenAt: type: string format: date-time diff --git a/messages/de.json b/messages/de.json index 53964b929..5e17d1bf6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6555,7 +6555,8 @@ "title": "Empfangene Daten", "summary": "{count} Datentypen, neuestes {relative}", "quiet": "{count} still", - "workouts": "Workouts" + "workouts": "Workouts", + "medicationDoses": "Medikamenteneinnahmen" }, "integrationFallback": { "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte." @@ -7131,6 +7132,7 @@ "title": "Nach Datentyp", "lastReceived": "Zuletzt empfangen {relative}", "lastNewValue": "Letzter neuer Wert {relative}", + "lastBackground": "Zuletzt im Hintergrund {relative}", "never": "Noch nicht empfangen" } }, diff --git a/messages/en.json b/messages/en.json index 3ca80a0cf..b08ee23b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6555,7 +6555,8 @@ "title": "Data received", "summary": "{count} data types, newest {relative}", "quiet": "{count} quiet", - "workouts": "Workouts" + "workouts": "Workouts", + "medicationDoses": "Medication doses" }, "integrationFallback": { "description": "Configured on this account without a settings card of its own." @@ -7131,6 +7132,7 @@ "title": "By data type", "lastReceived": "Last received {relative}", "lastNewValue": "Last new value {relative}", + "lastBackground": "Last in background {relative}", "never": "Not received yet" } }, diff --git a/messages/es.json b/messages/es.json index 74f8ca3a9..9bf9001d7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6555,7 +6555,8 @@ "title": "Datos recibidos", "summary": "{count} tipos de datos, más reciente {relative}", "quiet": "{count} en silencio", - "workouts": "Entrenamientos" + "workouts": "Entrenamientos", + "medicationDoses": "Tomas de medicación" }, "integrationFallback": { "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia." @@ -7131,6 +7132,7 @@ "title": "Por tipo de dato", "lastReceived": "Última recepción {relative}", "lastNewValue": "Último valor nuevo {relative}", + "lastBackground": "Última vez en segundo plano {relative}", "never": "Aún no recibido" } }, diff --git a/messages/fr.json b/messages/fr.json index 58e438cb9..878a0384b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6555,7 +6555,8 @@ "title": "Données reçues", "summary": "{count} types de données, plus récent {relative}", "quiet": "{count} silencieux", - "workouts": "Séances" + "workouts": "Séances", + "medicationDoses": "Prises de médicaments" }, "integrationFallback": { "description": "Configuré sur ce compte sans carte de réglages dédiée." @@ -7131,6 +7132,7 @@ "title": "Par type de donnée", "lastReceived": "Dernière réception {relative}", "lastNewValue": "Dernière nouvelle valeur {relative}", + "lastBackground": "Dernière fois en arrière-plan {relative}", "never": "Pas encore reçu" } }, diff --git a/messages/it.json b/messages/it.json index 503a63ab1..2c3c7df29 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6555,7 +6555,8 @@ "title": "Dati ricevuti", "summary": "{count} tipi di dati, più recente {relative}", "quiet": "{count} in silenzio", - "workouts": "Allenamenti" + "workouts": "Allenamenti", + "medicationDoses": "Assunzioni di farmaci" }, "integrationFallback": { "description": "Configurato su questo account ma senza una scheda di impostazioni propria." @@ -7131,6 +7132,7 @@ "title": "Per tipo di dato", "lastReceived": "Ultima ricezione {relative}", "lastNewValue": "Ultimo nuovo valore {relative}", + "lastBackground": "Ultima volta in background {relative}", "never": "Non ancora ricevuto" } }, diff --git a/messages/ko.json b/messages/ko.json index e6407a3fb..1d65cae91 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6555,7 +6555,8 @@ "title": "받은 데이터", "summary": "데이터 유형 {count}개, 최신 {relative}", "quiet": "{count}개 잠잠", - "workouts": "운동" + "workouts": "운동", + "medicationDoses": "약 복용" }, "integrationFallback": { "description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요." @@ -7131,6 +7132,7 @@ "title": "데이터 유형별", "lastReceived": "마지막 수신 {relative}", "lastNewValue": "마지막 새 값 {relative}", + "lastBackground": "마지막 백그라운드 수신 {relative}", "never": "아직 수신되지 않음" } }, diff --git a/messages/pl.json b/messages/pl.json index 61fab36d0..6093866f9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6555,7 +6555,8 @@ "title": "Odebrane dane", "summary": "{count} typów danych, najnowsze {relative}", "quiet": "{count} bez danych", - "workouts": "Treningi" + "workouts": "Treningi", + "medicationDoses": "Dawki leków" }, "integrationFallback": { "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień." @@ -7131,6 +7132,7 @@ "title": "Według typu danych", "lastReceived": "Ostatnio odebrano {relative}", "lastNewValue": "Ostatnia nowa wartość {relative}", + "lastBackground": "Ostatnio w tle {relative}", "never": "Jeszcze nie odebrano" } }, diff --git a/prisma/migrations/0401_healthkit_type_sync_text_and_background/migration.sql b/prisma/migrations/0401_healthkit_type_sync_text_and_background/migration.sql new file mode 100644 index 000000000..0056be00e --- /dev/null +++ b/prisma/migrations/0401_healthkit_type_sync_text_and_background/migration.sql @@ -0,0 +1,23 @@ +-- Per-type HealthKit arrival ledger: room for non-measurement types, and the +-- last background arrival per type. +-- +-- `type` moves from the `measurement_type` enum to text so the ledger can +-- also record Apple Health medication doses (`MEDICATION_DOSE`), which are +-- not a measurement type. Every stored enum value converts to its own label +-- through the enum's text output, so no row changes meaning. The table holds +-- at most one row per (user, delivered type), a few dozen per account; the +-- rewrite and the primary-key rebuild it implies are cheap at any size this +-- table reaches, and the key keeps its name and columns. +ALTER TABLE "healthkit_type_syncs" + ALTER COLUMN "type" SET DATA TYPE TEXT USING "type"::text; + +-- When a batch with `syncTrigger = background` last carried the type. A +-- later foreground or manual arrival leaves it standing. +ALTER TABLE "healthkit_type_syncs" ADD COLUMN "last_background_at" TIMESTAMP(3); + +-- A row whose latest arrival was already a background one says exactly that, +-- so it starts with that date rather than null. Rows whose latest arrival +-- came another way cannot know an earlier background date and stay null. +UPDATE "healthkit_type_syncs" +SET "last_background_at" = "last_received_at" +WHERE "last_trigger" = 'background'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0c436a25..f78946ab2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4005,16 +4005,25 @@ model ManagedProfileHandover { /// batch route; read by the Apple Health card's per-type freshness list. /// Diagnostic state: rebuilt by the next sync, so it is not backed up. model HealthKitTypeSync { - userId String @map("user_id") - type MeasurementType + userId String @map("user_id") + /// A `MeasurementType` name for rows the measurement batch writes, or + /// `MEDICATION_DOSE` for Apple Health doses the intake bulk route + /// receives. Text, not the enum, since v1.42.4 (migration 0401) because a + /// dose is not a measurement type. + type String /// Last batch that carried this type. - lastReceivedAt DateTime @map("last_received_at") + lastReceivedAt DateTime @map("last_received_at") /// `syncTrigger` of that batch /// (`foreground` / `background` / `push` / `manual`), NULL when absent. - lastTrigger String? @map("last_trigger") + lastTrigger String? @map("last_trigger") /// Last batch that inserted or updated a sample of /// this type (as opposed to sending only duplicates). - lastNewSampleAt DateTime? @map("last_new_sample_at") + lastNewSampleAt DateTime? @map("last_new_sample_at") + /// Last batch with `syncTrigger = background` that carried this type. A + /// later foreground, push or manual arrival leaves it standing; NULL when + /// none has (or only before v1.42.4, except a row whose latest arrival was + /// a background one, which migration 0401 seeded). + lastBackgroundAt DateTime? @map("last_background_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index f029d6a7f..deaedee17 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -76,6 +76,19 @@ beforeEach(() => { } as never); }); +/** + * The route issues two raw reads: the newest-sample probe per measurement type + * and (v1.42.4) the newest Apple Health dose. Answer each by its SQL. + */ +function mockProbes(rows: unknown, doseLastSeen: Date | null = null): void { + vi.mocked(prisma.$queryRaw).mockImplementation((async ( + strings: TemplateStringsArray, + ) => + strings.join("?").includes("medication_intake_events") + ? [{ last_seen: doseLastSeen }] + : rows) as never); +} + const callGet = GET as unknown as (req: NextRequest) => Promise; function makeGetReq(): NextRequest { return new NextRequest("http://localhost/api/integrations/healthkit"); @@ -313,9 +326,7 @@ describe("GET /api/integrations/healthkit — sync health", () => { healthKitLastSyncedAt: null, } as never); vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); - vi.mocked(prisma.$queryRaw).mockResolvedValue([ - { type: "PULSE", last_seen: ago(DAY) }, - ] as never); + mockProbes([{ type: "PULSE", last_seen: ago(DAY) }] as never); const body = await read(); expect(body.syncHealth.verdict).toBe("fresh"); expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]); @@ -330,7 +341,7 @@ describe("GET /api/integrations/healthkit — sync health", () => { "RESPIRATORY_RATE", "PULSE", ]); - vi.mocked(prisma.$queryRaw).mockResolvedValue([ + mockProbes([ { type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) }, { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, ] as never); @@ -441,7 +452,7 @@ describe("GET /api/integrations/healthkit — sync health", () => { "RESPIRATORY_RATE", "PULSE", ]); - vi.mocked(prisma.$queryRaw).mockResolvedValue([ + mockProbes([ { type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) }, { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, ] as never); @@ -476,15 +487,74 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt"); }); + // v1.42.4 — Apple Health doses join the list with their own arrival facts, + // and every entry carries the last background arrival beside the last one. + it("serves a MEDICATION_DOSE entry and the last background arrival", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + const doseAt = ago(40 * DAY); + mockProbes([{ type: "PULSE", last_seen: ago(60 * 60 * 1000) }], doseAt); + const receivedAt = ago(60 * 60 * 1000); + const backgroundAt = ago(5 * 60 * 60 * 1000); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "MEDICATION_DOSE", + lastReceivedAt: receivedAt, + lastTrigger: "foreground", + lastNewSampleAt: null, + lastBackgroundAt: backgroundAt, + }, + { + type: "PULSE", + lastReceivedAt: receivedAt, + lastTrigger: "background", + lastNewSampleAt: receivedAt, + lastBackgroundAt: null, + }, + ] as never); + + const body = await read(); + expect(body.metricFreshness.map((e) => e.type)).toEqual([ + "MEDICATION_DOSE", + "PULSE", + ]); + const byType = Object.fromEntries( + body.metricFreshness.map((entry) => [entry.type, entry]), + ); + expect(byType.MEDICATION_DOSE).toEqual({ + type: "MEDICATION_DOSE", + lastSeenAt: doseAt.toISOString(), + // Forty days without a dose is not a dead pipe: as-needed medications. + stale: false, + lastReceivedAt: receivedAt.toISOString(), + lastTrigger: "foreground", + lastNewSampleAt: null, + lastBackgroundAt: backgroundAt.toISOString(), + }); + expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null }); + }); + + it("leaves the dose entry out when there is no Apple Health dose", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(DAY), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + mockProbes([{ type: "PULSE", last_seen: ago(DAY) }], null); + const body = await read(); + expect(body.metricFreshness.map((e) => e.type)).toEqual(["PULSE"]); + }); + it("leaves the arrival facts off when the ledger read fails", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, healthKitLastSyncedAt: ago(DAY), } as never); vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); - vi.mocked(prisma.$queryRaw).mockResolvedValue([ - { type: "PULSE", last_seen: ago(DAY) }, - ] as never); + mockProbes([{ type: "PULSE", last_seen: ago(DAY) }] as never); vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue( new Error("ledger hiccup"), ); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 7a3307145..bde6b3da4 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -26,7 +26,10 @@ import { newestAppleHealthDataAt, withHealthKitArrivals, } from "@/lib/integrations/healthkit-type-sync"; -import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; +import { + getAppleHealthDoseFreshness, + getSourceFreshness, +} from "@/lib/integrations/metric-freshness"; import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load"; import { classifyMetricFreshness, @@ -177,12 +180,22 @@ export const GET = apiHandler(async () => { // `health` is the unified connection state; fail-soft to null for the same // reason as the reads beside it, so the config the phone needs never 500s // over a diagnostic. - const [samples, arrivals, syncProgress, health] = await Promise.all([ - getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), - getHealthKitTypeArrivals(user.id).catch(() => null), - getAppleHealthSyncProgress(user.id).catch(() => null), - loadOneConnectionHealth(user.id, "apple-health").catch(() => null), - ]); + // v1.42.4 — Apple Health doses join the list as `MEDICATION_DOSE`, so the + // phone can read when doses last arrived from the same entries as every + // other type. Fail-soft on its own: a failed dose read drops that one entry. + const [measurementSamples, doseSample, arrivals, syncProgress, health] = + await Promise.all([ + getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), + getAppleHealthDoseFreshness(user.id).catch(() => null), + getHealthKitTypeArrivals(user.id).catch(() => null), + getAppleHealthSyncProgress(user.id).catch(() => null), + loadOneConnectionHealth(user.id, "apple-health").catch(() => null), + ]); + const samples = doseSample + ? [...measurementSamples, doseSample].sort((a, b) => + a.type.localeCompare(b.type), + ) + : measurementSamples; // No `connected` hint: Apple Health has nothing to connect or disconnect, so // "never delivered" resolves to `pending_first_sync`, not `disconnected`. // "Delivered" is read off the data, not only off the user column: that diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 4b42d4efb..299b0ff1b 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -73,6 +73,7 @@ import { findFoldedWindowDuplicates, } from "@/lib/measurements/folded-window"; import { + HEALTHKIT_SYNC_TRIGGERS, recordHealthKitTypeArrivals, type HealthKitTypeArrival, } from "@/lib/integrations/healthkit-type-sync"; @@ -198,7 +199,7 @@ const batchEntrySchema = z.object({ // pre-#66 caller stays byte-for-byte unchanged. // v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to // the app simply being open (`foreground`). -const syncTriggerEnum = z.enum(["foreground", "background", "push", "manual"]); +const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS); const batchPayloadSchema = z.object({ entries: z.array(batchEntrySchema).min(1), diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 37202fac4..d5dfdb2de 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -29,6 +29,10 @@ * per Apple dose) * } * + * Top level also takes `syncTrigger?` (foreground | background | push | + * manual, v1.42.4), diagnostic only: it dates the `MEDICATION_DOSE` entry of + * the Apple Health per-type arrival record and changes no row. + * * Response (always 200): processed / inserted / duplicates / skipped / * entries[] mirroring the measurements + workouts batch envelope. * @@ -53,6 +57,11 @@ import { import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { markSyncCheckpoint } from "@/lib/sync/checkpoint"; +import { + HEALTHKIT_SYNC_TRIGGERS, + MEDICATION_DOSE_ARRIVAL_TYPE, + recordHealthKitTypeArrivals, +} from "@/lib/integrations/healthkit-type-sync"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForDay, @@ -164,6 +173,10 @@ const bulkEntrySchema = z const bulkPayloadSchema = z.object({ entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH), + // v1.42.4 — what woke the phone for this sync, the same diagnostic-only + // field `POST /api/measurements/batch` takes. It changes no row; it only + // feeds the `MEDICATION_DOSE` arrival record below. + syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(), }); // v1.8.2 — `updated` joins the per-entry status vocabulary: a write that @@ -243,7 +256,7 @@ async function postBulk(request: NextRequest): Promise { }); } - const { entries } = parsed.data; + const { entries, syncTrigger } = parsed.data; // Up-front ownership check — the user can only POST intake events // against medications they own. One read covers every medicationId @@ -992,6 +1005,28 @@ async function postBulk(request: NextRequest): Promise { })(); } + // v1.42.4 — Apple Health doses get the per-type arrival record the + // measurement batch keeps for its types: when they last arrived, under which + // trigger, when a sync last brought a new or changed dose, and when a + // background sync last carried them. Entries sent with `source: + // "APPLE_HEALTH"` only; a dose taken in the app is not an Apple Health + // arrival. A replay answered `duplicate` still counts as an arrival, the + // same rule as the batch route. + const doseArrival = { accepted: 0, newSamples: 0 }; + for (const result of results) { + if (entries[result.index]?.source !== "APPLE_HEALTH") continue; + if (result.status === "skipped") continue; + doseArrival.accepted += 1; + if (result.status !== "duplicate") doseArrival.newSamples += 1; + } + if (doseArrival.accepted > 0) { + await recordHealthKitTypeArrivals(prisma, { + userId: user.id, + counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]), + trigger: syncTrigger ?? null, + }); + } + // The SyncMode checkpoint belongs to the push that moved the data, not to // the `/api/sync/state` handshake that reports it. This endpoint is one of // the three domains that handshake summarises, so an intake drain advances diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index 85181296e..ee0431664 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -133,7 +133,8 @@ function DeliveryDiagnostic({ * under which trigger, and when one last brought a new value. The newest * sample's time on the right of the row says when the reading was TAKEN; this * says when the server RECEIVED it, which is what tells a type that only - * arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing + * arrives on "Sync all" from one the phone delivers by itself (#1173), plus + * the last background arrival when a later one came another way. Nothing * renders when the server sent no arrival facts (the workouts row, or a failed * ledger read), so absence is never painted as "not received". */ @@ -176,6 +177,18 @@ export function ArrivalDetail({ }), ); } + // The last background arrival, when a later one came another way: opening + // the app to look would otherwise hide that the phone delivers by itself. + if ( + entry.lastBackgroundAt && + entry.lastBackgroundAt !== entry.lastReceivedAt + ) { + parts.push( + t("settings.appleHealth.freshness.lastBackground", { + relative: relative(entry.lastBackgroundAt), + }), + ); + } return ( { if (type === WORKOUT_TYPE) return t("settings.integrationFreshness.workouts"); + if (type === MEDICATION_DOSE_TYPE) + return t("settings.integrationFreshness.medicationDoses"); const key = MEASUREMENT_TYPE_LABEL_KEYS[type]; return key ? t(key) : humanise(type); }; diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 20570d0b0..4c456242a 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -3,9 +3,12 @@ * * The batch route records, per measurement type it received, when the type * last arrived, under which `syncTrigger`, and when it last brought a new or - * updated sample (as opposed to duplicates only). The Apple Health card reads - * it to show which types are actually flowing. A scoped ingest credential - * does not write it, the same rule as `lastSyncedAt`. + * updated sample (as opposed to duplicates only). Since v1.42.4 the intake + * bulk route records Apple Health doses the same way under the key + * `MEDICATION_DOSE`, and every row also keeps when a `background` batch last + * carried the type. The Apple Health card reads it to show which types are + * actually flowing. A scoped ingest credential does not write it, the same + * rule as `lastSyncedAt`. * * Sample time (`max(measuredAt)`, what the freshness list showed before) and * arrival time are different questions. A type the phone only hands over @@ -17,19 +20,32 @@ import type { MeasurementType, Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { logCaught } from "@/lib/logging/signal"; import { + MEDICATION_DOSE_FRESHNESS_TYPE, WORKOUT_FRESHNESS_TYPE, type MetricFreshnessEntry, } from "./sync-verdict"; -export type HealthKitSyncTrigger = - "foreground" | "background" | "push" | "manual"; - -const TRIGGERS: ReadonlySet = new Set([ +/** The `syncTrigger` values the Apple Health ingest routes accept. */ +export const HEALTHKIT_SYNC_TRIGGERS = [ "foreground", "background", "push", "manual", -]); +] as const; + +export type HealthKitSyncTrigger = (typeof HEALTHKIT_SYNC_TRIGGERS)[number]; + +const TRIGGERS: ReadonlySet = new Set(HEALTHKIT_SYNC_TRIGGERS); + +/** + * The ledger key for Apple Health medication doses, which are not a + * `MeasurementType`. Also the `type` of their `metricFreshness` entry. + */ +export const MEDICATION_DOSE_ARRIVAL_TYPE = MEDICATION_DOSE_FRESHNESS_TYPE; + +/** A ledger key: a measurement type, or the dose key above. */ +export type HealthKitArrivalType = + MeasurementType | typeof MEDICATION_DOSE_ARRIVAL_TYPE; /** What one batch did for one type. */ export interface HealthKitTypeArrival { @@ -44,7 +60,9 @@ export interface HealthKitTypeArrival { * types). `last_received_at` and `last_trigger` describe the latest batch * that carried the type, so they are overwritten; `last_new_sample_at` only * moves when this batch brought something new, so a duplicates-only re-send - * keeps the earlier date standing. + * keeps the earlier date standing. `last_background_at` moves only on a + * `background` batch, so opening the app afterwards does not hide the + * earlier background arrival. * * Best effort: the ledger is a diagnostic beside rows that are already * written, so a failure here leaves a `warn` line and never fails the ingest. @@ -53,7 +71,7 @@ export async function recordHealthKitTypeArrivals( tx: Prisma.TransactionClient, input: { userId: string; - counts: ReadonlyMap; + counts: ReadonlyMap; trigger: HealthKitSyncTrigger | null; at?: Date; }, @@ -70,13 +88,16 @@ export async function recordHealthKitTypeArrivals( // serialised in the process's local zone and these are zone-less UTC // columns (same reasoning as the access-token sunset in refresh-token.ts). const at = (input.at ?? new Date()).toISOString(); + const background = input.trigger === "background"; try { await tx.$executeRaw` INSERT INTO "healthkit_type_syncs" - ("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at") - SELECT ${input.userId}, u.t::"measurement_type", + ("user_id", "type", "last_received_at", "last_trigger", + "last_new_sample_at", "last_background_at") + SELECT ${input.userId}, u.t, ${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger}, - CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END + CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END, + CASE WHEN ${background}::boolean THEN ${at}::timestamptz AT TIME ZONE 'UTC' END FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n) ON CONFLICT ("user_id", "type") DO UPDATE SET "last_received_at" = EXCLUDED."last_received_at", @@ -84,6 +105,10 @@ export async function recordHealthKitTypeArrivals( "last_new_sample_at" = COALESCE( EXCLUDED."last_new_sample_at", "healthkit_type_syncs"."last_new_sample_at" + ), + "last_background_at" = COALESCE( + EXCLUDED."last_background_at", + "healthkit_type_syncs"."last_background_at" ) `; } catch (error) { @@ -98,10 +123,11 @@ export interface HealthKitTypeArrivalRecord { lastReceivedAt: string; lastTrigger: HealthKitSyncTrigger | null; lastNewSampleAt: string | null; + lastBackgroundAt: string | null; } /** - * Every recorded type for one account, keyed by `MeasurementType`. A stored + * Every recorded type for one account, keyed by the ledger key. A stored * trigger outside the known set (none is written, but the column is text) * reads as null rather than reaching the client as an unknown label. */ @@ -115,6 +141,7 @@ export async function getHealthKitTypeArrivals( lastReceivedAt: true, lastTrigger: true, lastNewSampleAt: true, + lastBackgroundAt: true, }, }); const out = new Map(); @@ -126,6 +153,7 @@ export async function getHealthKitTypeArrivals( ? (row.lastTrigger as HealthKitSyncTrigger) : null, lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null, + lastBackgroundAt: row.lastBackgroundAt?.toISOString() ?? null, }); } return out; @@ -165,11 +193,13 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { lastTrigger?: HealthKitSyncTrigger | null; /** When a live sync last brought a new or updated sample of it. */ lastNewSampleAt?: string | null; + /** When a `background` sync last carried it; a later foreground one keeps it. */ + lastBackgroundAt?: string | null; } /** * Lay the arrival ledger over the per-type freshness entries. Every - * measurement type gets the three fields, null when no live sync has carried + * measurement type and the `MEDICATION_DOSE` entry get the four fields, null when no live sync has carried * it yet (a type that so far came only from the export import, or from before * the ledger existed). The `WORKOUTS` entry is left without them: workouts * arrive through their own batch route, which the ledger does not cover, and @@ -189,6 +219,7 @@ export function withHealthKitArrivals( lastReceivedAt: arrival?.lastReceivedAt ?? null, lastTrigger: arrival?.lastTrigger ?? null, lastNewSampleAt: arrival?.lastNewSampleAt ?? null, + lastBackgroundAt: arrival?.lastBackgroundAt ?? null, }; }); } diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts index d45eb557d..bcda04877 100644 --- a/src/lib/integrations/metric-freshness.ts +++ b/src/lib/integrations/metric-freshness.ts @@ -29,6 +29,7 @@ import type { import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import type { IntegrationKey } from "./status"; import { + MEDICATION_DOSE_FRESHNESS_TYPE, WORKOUT_FRESHNESS_TYPE, type MetricFreshnessSample, } from "./sync-verdict"; @@ -185,6 +186,34 @@ export async function getSourceFreshness( return sortByType(entries); } +/** + * The newest Apple Health medication dose, as the `MEDICATION_DOSE` sample of + * the Apple Health freshness list (v1.42.4). A dose row carries an + * `externalId` only when it came from Apple Health (the intake bulk route + * accepts one solely with `source: "APPLE_HEALTH"`, and a slot write that + * converges onto an existing row keeps the id), so that column, not + * `source`, is what identifies them. A taken dose is dated by when it was + * taken, a skipped one by its slot. Null when there is no live Apple Health + * dose, so the entry is absent rather than invented. + */ +export async function getAppleHealthDoseFreshness( + userId: string, +): Promise { + const rows = await prisma.$queryRaw>` + SELECT max(COALESCE(e."taken_at", e."scheduled_for")) AS "last_seen" + FROM "medication_intake_events" e + WHERE e."user_id" = ${userId} + AND e."external_id" IS NOT NULL + AND e."deleted_at" IS NULL + `; + const lastSeen = rows[0]?.last_seen; + if (!lastSeen) return null; + return { + type: MEDICATION_DOSE_FRESHNESS_TYPE, + lastSeenAt: new Date(lastSeen).toISOString(), + }; +} + /** * Compute per-`(integration, type)` last-value timestamps for the sync * integrations, keyed by `IntegrationKey`. Two grouped queries (measurements + diff --git a/src/lib/integrations/sync-verdict.ts b/src/lib/integrations/sync-verdict.ts index d0313902b..2a0032fde 100644 --- a/src/lib/integrations/sync-verdict.ts +++ b/src/lib/integrations/sync-verdict.ts @@ -271,14 +271,25 @@ export const SPARSE_METRIC_TYPES: ReadonlySet = new Set([ "AUDIO_EXPOSURE_EVENT", // People train irregularly; a quiet training week is not a broken pipe. "WORKOUTS", + // As-needed and paused medications leave weeks without a dose by design. + "MEDICATION_DOSE", ]); /** The pseudo-type carrying a source's newest workout, next to its metrics. */ export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS"; +/** + * The pseudo-type carrying Apple Health medication doses on the Apple Health + * freshness list (v1.42.4), and their key in the per-type arrival ledger. + */ +export const MEDICATION_DOSE_FRESHNESS_TYPE = "MEDICATION_DOSE"; + /** Newest recorded reading for one `(source, type)` pair, as queried. */ export interface MetricFreshnessSample { - /** The `MeasurementType` (e.g. "RESPIRATORY_RATE"), or `WORKOUTS`. */ + /** + * The `MeasurementType` (e.g. "RESPIRATORY_RATE"), `WORKOUTS`, or + * `MEDICATION_DOSE` on the Apple Health list. + */ type: string; /** ISO timestamp of the newest (non-deleted) reading for this metric. */ lastSeenAt: string; diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 44862fc08..1773fe2ac 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -203,7 +203,7 @@ const metricFreshnessEntry = z type: z .string() .describe( - "The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.", + "The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than fail.", ), lastSeenAt: z.iso .datetime({ offset: true }) @@ -245,11 +245,18 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry .describe( "When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.", ), + lastBackgroundAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + 'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.', + ), }) .meta({ id: "AppleHealthMetricFreshnessEntry", description: - "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42).", + 'Per-type Apple Health freshness: the newest sample\'s timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42). Since v1.42.4 the list can also hold a `MEDICATION_DOSE` entry: `lastSeenAt` is the newest live dose imported from Apple Health (its `takenAt`, or its slot for a skipped dose), and the arrival facts come from `POST /api/medications/intake/bulk` entries sent with `source: "APPLE_HEALTH"`. A dose recorded in the app does not move them. Doses are never flagged `stale`.', }); // Issue #778 — the two backfill-progress figures the server genuinely holds. diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index f5396914c..295ad924b 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -130,7 +130,7 @@ const batchPayloadSchema = z .enum(["foreground", "background", "push", "manual"]) .optional() .describe( - 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', + 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.', ), }) .meta({ diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 59c23b3cb..ac6ecd4e5 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -198,6 +198,12 @@ const bulkIntakeEntry = z const bulkIntakePayload = z .object({ entries: z.array(bulkIntakeEntry).min(1).max(500), + syncTrigger: z + .enum(["foreground", "background", "push", "manual"]) + .optional() + .describe( + 'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.', + ), }) .meta({ id: "BulkMedicationIntakeRequest",