diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f7350a170..eb8af506b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -24873,7 +24873,9 @@ components: description: '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`. It does not affect dedup, attribution, or how any sample is + 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.' type: string enum: @@ -72802,11 +72804,11 @@ components: description: Present on the GET read; omitted from the PATCH echo. $ref: "#/components/schemas/SyncHealth" metricFreshness: - description: Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH - echo. + description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the + GET read; omitted from the PATCH echo. type: array items: - $ref: "#/components/schemas/MetricFreshnessEntry" + $ref: "#/components/schemas/AppleHealthMetricFreshnessEntry" syncProgress: description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo. @@ -72874,7 +72876,7 @@ components: additionalProperties: false description: Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'. - MetricFreshnessEntry: + AppleHealthMetricFreshnessEntry: type: object properties: type: @@ -72889,13 +72891,44 @@ components: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`. + lastReceivedAt: + description: "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null + when no live sync has carried it yet: a type that so far came only from the export import, or from before + v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. + Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read + failed." + 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" + lastTrigger: + description: The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever + `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`. + anyOf: + - type: string + enum: + - foreground + - background + - push + - manual + description: What triggered a HealthKit batch. + - type: "null" + lastNewSampleAt: + description: 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`. + 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-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually - delivered appear — absence is absence, never an invented row. + 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)." AppleHealthSyncProgress: type: object properties: @@ -74763,6 +74796,28 @@ components: entry carries, plus the extras that provider has. The optional fields are not optional per request — each is present exactly for the providers named in its description and absent for the rest, so branch on `integration`, not on presence." + MetricFreshnessEntry: + type: object + properties: + type: + type: string + description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg. + lastSeenAt: + 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)))$ + description: Newest recorded reading for this type from this source. + stale: + type: boolean + description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single + revoked HealthKit permission). Only ever true when the verdict is `fresh`. + required: + - type + - lastSeenAt + - stale + additionalProperties: false + description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually + delivered appear — absence is absence, never an invented row. FitbitResumeEnvelope: type: object properties: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d95bceefb..2aec5f66e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3947,12 +3947,12 @@ model ManagedProfileHandover { model HealthKitTypeSync { userId String @map("user_id") type MeasurementType - /// @pending-consumer(#1173) Last batch that carried this type. + /// Last batch that carried this type. lastReceivedAt DateTime @map("last_received_at") - /// @pending-consumer(#1173) `syncTrigger` of that batch + /// `syncTrigger` of that batch /// (`foreground` / `background` / `push` / `manual`), NULL when absent. lastTrigger String? @map("last_trigger") - /// @pending-consumer(#1173) Last batch that inserted or updated a sample of + /// Last batch that inserted or updated a sample of /// this type (as opposed to sending only duplicates). lastNewSampleAt DateTime? @map("last_new_sample_at") diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index e3bda6a41..4a2083a39 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -91,8 +91,6 @@ const RELEASE_PENDING_PREFIXES: Record = { "environment.fields.europeanAqi": "v1.42 air-quality field label (#615)", "environment.fields.uvIndexMax": "v1.42 air-quality field label (#615)", "environment.fields.dustMax": "v1.42 air-quality field label (#615)", - "settings.appleHealth.freshness": - "v1.42 per-type HealthKit freshness list (#1173)", "insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)", "insights.workouts.manual.editAction": "v1.42 manual workout edit (#1162)", "insights.workouts.manual.updated": "v1.42 manual workout edit (#1162)", diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 26ac0eae7..fd6b34138 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -8,12 +8,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { create: vi.fn() }, // Per-metric freshness + first-run sync progress for the APPLE_HEALTH // source (#778). - measurement: { groupBy: vi.fn(), aggregate: vi.fn() }, + measurement: { aggregate: vi.fn() }, workout: { groupBy: vi.fn(), aggregate: vi.fn() }, + // v1.42 — the newest sample per live type is one raw probe per type, and + // the per-type arrival ledger (#1173) is read beside it. + $queryRaw: vi.fn(), + healthKitTypeSync: { findMany: vi.fn() }, }, toJson: (v: T) => v, })); +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -38,6 +46,7 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const SESSION_OK = { @@ -53,7 +62,9 @@ beforeEach(() => { } as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([] as never); vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never); vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ _count: { _all: 0 }, @@ -281,26 +292,20 @@ describe("GET /api/integrations/healthkit — sync health", () => { healthKitConfigJson: null, healthKitLastSyncedAt: ago(60 * 60 * 1000), } as never); - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ - { - source: "APPLE_HEALTH", - type: "RESPIRATORY_RATE", - _max: { measuredAt: ago(30 * DAY) }, - }, - { - source: "APPLE_HEALTH", - type: "PULSE", - _max: { measuredAt: ago(60 * 60 * 1000) }, - }, + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([ + "RESPIRATORY_RATE", + "PULSE", + ]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) }, + { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, ] as never); const body = await read(); // Only the Apple Health source is read. - const groupByArgs = vi.mocked(prisma.measurement.groupBy).mock - .calls[0]?.[0] as unknown as { - where: { source: { in: string[] } }; - }; - expect(groupByArgs.where.source.in).toEqual(["APPLE_HEALTH"]); + expect(vi.mocked(listLiveMeasurementTypes).mock.calls[0]?.[1]).toEqual({ + source: "APPLE_HEALTH", + }); const byType = Object.fromEntries( body.metricFreshness.map((entry) => [entry.type, entry.stale]), ); @@ -382,11 +387,75 @@ describe("GET /api/integrations/healthkit — sync health", () => { healthKitConfigJson: null, healthKitLastSyncedAt: ago(DAY), } as never); - vi.mocked(prisma.measurement.groupBy).mockRejectedValue( - new Error("groupBy hiccup"), + vi.mocked(listLiveMeasurementTypes).mockRejectedValue( + new Error("live-type hiccup"), ); const body = await read(); expect(body.metricFreshness).toEqual([]); expect(body.syncHealth.verdict).toBe("fresh"); }); + + // #1173 — when a type last ARRIVED and under which trigger, beside when its + // newest sample was taken. A type that only ever comes with "Sync all" is + // visible as `manual`; a type no live sync carried yet reads null. + it("lays the per-type arrival facts over the freshness entries", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([ + "RESPIRATORY_RATE", + "PULSE", + ]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) }, + { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, + ] as never); + vi.mocked(prisma.workout.groupBy).mockResolvedValue([ + { source: "APPLE_HEALTH", _max: { startedAt: ago(DAY) } }, + ] as never); + const receivedAt = ago(3 * 60 * 60 * 1000); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "RESPIRATORY_RATE", + lastReceivedAt: receivedAt, + lastTrigger: "manual", + lastNewSampleAt: null, + }, + ] as never); + + const body = await read(); + const byType = Object.fromEntries( + body.metricFreshness.map((entry) => [entry.type, entry]), + ); + expect(byType.RESPIRATORY_RATE).toMatchObject({ + lastReceivedAt: receivedAt.toISOString(), + lastTrigger: "manual", + lastNewSampleAt: null, + }); + expect(byType.PULSE).toMatchObject({ + lastReceivedAt: null, + lastTrigger: null, + lastNewSampleAt: null, + }); + // Workouts ride their own batch route, which the ledger does not cover. + expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt"); + }); + + 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); + vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue( + new Error("ledger hiccup"), + ); + const body = await read(); + expect(body.metricFreshness).toHaveLength(1); + expect(body.metricFreshness[0]).not.toHaveProperty("lastReceivedAt"); + }); }); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e9428e4c1..e104168ca 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -21,6 +21,10 @@ import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; +import { + getHealthKitTypeArrivals, + withHealthKitArrivals, +} from "@/lib/integrations/healthkit-type-sync"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; import { classifyMetricFreshness, @@ -166,9 +170,13 @@ export const GET = apiHandler(async () => { legacyLastSyncedAt: lastSyncedAt, cadence: PUSH_CADENCE, }); - const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch( - () => [], - ); + // #1173 — when each type last ARRIVED and under which trigger, beside when + // its newest sample was taken. Fail-soft to null, which leaves the entries + // without arrival fields rather than reporting every type as never received. + const [samples, arrivals] = await Promise.all([ + getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), + getHealthKitTypeArrivals(user.id).catch(() => null), + ]); // Issue #778 — a first-run backfill used to be invisible from the web: the // card said "last sync 2 minutes ago" while giving no sense of how much had // arrived or how far back it reached. These two figures (row count + oldest @@ -186,7 +194,10 @@ export const GET = apiHandler(async () => { lastSyncTrigger, lastBackgroundSyncAt, syncHealth, - metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), + metricFreshness: withHealthKitArrivals( + classifyMetricFreshness(samples, syncHealth.verdict), + arrivals, + ), syncProgress, }); }); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 75f54a607..1c0dde365 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -13,11 +13,16 @@ vi.mock("@/lib/db", () => ({ fitbitConnection: { findUnique: vi.fn(async () => null) }, googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, - measurement: { groupBy: vi.fn(async () => []) }, + $queryRaw: vi.fn(async () => []), workout: { groupBy: vi.fn(async () => []) }, }, })); +// v1.42 — the per-source type walk that replaced the measurement groupBy. +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: vi.fn(async () => []), +})); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/api-response", () => ({ @@ -53,6 +58,7 @@ vi.mock("@/lib/oura/credentials", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const userFind = prisma.user.findUnique as ReturnType; @@ -315,7 +321,7 @@ describe("/api/integrations/status — the freshness read failed", () => { }); it("reports the degradation instead of passing an empty list off as an answer", async () => { - (prisma.measurement.groupBy as ReturnType).mockRejectedValue( + vi.mocked(listLiveMeasurementTypes).mockRejectedValue( new Error("statement timeout"), ); @@ -331,9 +337,7 @@ describe("/api/integrations/status — the freshness read failed", () => { }); it("reports no degradation when the read simply found nothing", async () => { - (prisma.measurement.groupBy as ReturnType).mockResolvedValue( - [], - ); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]); (prisma.workout.groupBy as ReturnType).mockResolvedValue([]); const envelope = await fetchEnvelope(); diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index fdc7b43e2..1a483c1d5 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -38,7 +38,7 @@ vi.mock("@tanstack/react-query", () => ({ }), })); -import { AppleHealthCard } from "../apple-health-card"; +import { AppleHealthCard, ArrivalDetail } from "../apple-health-card"; function render(locale: Locale = "en") { return renderToStaticMarkup( @@ -404,3 +404,77 @@ describe(" — sync progress (#778)", () => { } }); }); + +describe(" — per-type arrivals (#1173)", () => { + const now = new Date("2026-10-08T12:00:00.000Z"); + const hoursAgo = (h: number) => + new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); + const base = { + type: "RESPIRATORY_RATE", + lastSeenAt: hoursAgo(5), + stale: false, + }; + const t = (key: string, params?: Record) => { + const value = key + .split(".") + .reduce( + (node, part) => (node as Record)?.[part], + en, + ); + if (typeof value !== "string") return key; + return value.replace(/\{(\w+)\}/g, (_, name: string) => + String(params?.[name] ?? `{${name}}`), + ); + }; + const html = (entry: Parameters[0]["entry"]) => + renderToStaticMarkup(); + + it("names when a type last arrived and that only Sync all carried it", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(2), + lastTrigger: "manual", + lastNewSampleAt: hoursAgo(2), + }); + expect(out).toContain('data-trigger="manual"'); + expect(out).toContain("Last received 2 h ago"); + expect(out).toContain("from Sync all"); + // The last arrival brought something new, so no second date is needed. + expect(out).not.toContain("Last new value"); + }); + + it("adds the last new value when the latest arrival held duplicates only", () => { + const out = html({ + ...base, + lastReceivedAt: hoursAgo(1), + lastTrigger: "background", + lastNewSampleAt: hoursAgo(72), + }); + expect(out).toContain("in the background"); + expect(out).toContain("Last new value 3 d ago"); + }); + + it("says a type has not arrived through live sync yet", () => { + const out = html({ + ...base, + lastReceivedAt: null, + lastTrigger: null, + lastNewSampleAt: null, + }); + expect(out).toContain('data-state="never"'); + expect(out).toContain("Not received yet"); + }); + + it("renders nothing when the server sent no arrival facts", () => { + expect(html(base)).toBe(""); + }); + + it("titles the list by data type", () => { + statusPayload = { + lastSyncedAt: hoursAgo(1), + syncHealth: { verdict: "fresh", since: null }, + metricFreshness: [{ ...base }], + }; + expect(render()).toContain("By data type"); + }); +}); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index a65f522fa..403594b6f 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -25,10 +25,8 @@ import { import { Button } from "@/components/ui/button"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import type { - MetricFreshnessEntry, - SyncHealth, -} from "@/lib/integrations/sync-verdict"; +import type { HealthKitFreshnessEntry } from "@/lib/integrations/healthkit-type-sync"; +import type { SyncHealth } from "@/lib/integrations/sync-verdict"; import { queryKeys } from "@/lib/query-keys"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; @@ -43,7 +41,8 @@ interface HealthKitStatus { /** When data last arrived without the app being opened. */ lastBackgroundSyncAt?: string | null; syncHealth?: SyncHealth; - metricFreshness?: MetricFreshnessEntry[]; + /** Per type: newest sample, plus when it last arrived and how (#1173). */ + metricFreshness?: HealthKitFreshnessEntry[]; /** Server-side backfill progress (#778); null when the read failed. */ syncProgress?: { recordsAccepted: number; @@ -135,6 +134,65 @@ function DeliveryDiagnostic({ ); } +/** + * The second line of a type's row: when a live sync last carried the type, + * 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 + * renders when the server sent no arrival facts (the workouts row, or a failed + * ledger read), so absence is never painted as "not received". + */ +export function ArrivalDetail({ + entry, + t, + now, +}: { + entry: HealthKitFreshnessEntry; + t: (key: string, params?: Record) => string; + now?: Date; +}) { + if (entry.lastReceivedAt === undefined) return null; + if (entry.lastReceivedAt === null) { + return ( + + {t("settings.appleHealth.freshness.never")} + + ); + } + const reference = (now ?? new Date()).getTime(); + const relative = (iso: string) => + formatRelative(Math.max(0, reference - new Date(iso).getTime()), t); + const trigger = entry.lastTrigger ?? null; + const parts = [ + t("settings.appleHealth.freshness.lastReceived", { + relative: relative(entry.lastReceivedAt), + }), + t( + trigger + ? TRIGGER_KEYS[trigger] + : "settings.appleHealth.delivery.trigger.unknown", + ), + ]; + // Only worth a mention when the last arrival brought duplicates alone. + if (entry.lastNewSampleAt && entry.lastNewSampleAt !== entry.lastReceivedAt) { + parts.push( + t("settings.appleHealth.freshness.lastNewValue", { + relative: relative(entry.lastNewSampleAt), + }), + ); + } + return ( + + {parts.join(" · ")} + + ); +} + /** * First-run backfill progress (#778). During the initial HealthKit backfill * the card said nothing about how much had arrived or how far back it @@ -269,6 +327,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { } />
diff --git a/src/components/settings/integrations/metric-freshness-disclosure.tsx b/src/components/settings/integrations/metric-freshness-disclosure.tsx index 31f2d2b08..ae9fba69a 100644 --- a/src/components/settings/integrations/metric-freshness-disclosure.tsx +++ b/src/components/settings/integrations/metric-freshness-disclosure.tsx @@ -20,7 +20,7 @@ * "expected but missing" row is invented. */ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { AlertTriangle, ChevronDown } from "lucide-react"; import { formatRelative } from "@/components/settings/integration-status-pill"; @@ -42,16 +42,27 @@ function humanise(type: string): string { return words.charAt(0).toUpperCase() + words.slice(1); } -export function MetricFreshnessDisclosure({ +export function MetricFreshnessDisclosure< + Entry extends MetricFreshnessEntry = MetricFreshnessEntry, +>({ entries, idPrefix, now, + title, + detail, }: { - entries: MetricFreshnessEntry[] | undefined; + entries: Entry[] | undefined; /** Namespaces the disclosure's ids so several cards can coexist. */ idPrefix: string; /** Override "now" for deterministic testing. */ now?: Date; + /** Replaces the shared "received data" heading. */ + title?: string; + /** + * A second line under a row, for a card that knows more per type than the + * newest sample (the Apple Health card's arrival facts, #1173). + */ + detail?: (entry: Entry) => ReactNode; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); @@ -101,7 +112,7 @@ export function MetricFreshnessDisclosure({ )} /> - {t("settings.integrationFreshness.title")} + {title ?? t("settings.integrationFreshness.title")} {t("settings.integrationFreshness.summary", { @@ -119,34 +130,44 @@ export function MetricFreshnessDisclosure({ {open && (
    - {sorted.map((entry) => ( -
  • - - {entry.stale ? ( - -
  • - {relative(entry.lastSeenAt)} - -
  • - ))} +
    + + {entry.stale ? ( + + +
    + {extra ? ( +
    + {extra} +
    + ) : null} + + ); + })}
)}
diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts index c5f3c0a5d..635efde2f 100644 --- a/src/lib/integrations/__tests__/metric-freshness.test.ts +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -8,14 +8,35 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const groupByMock = vi.hoisted(() => vi.fn()); +/** + * The fixtures keep the grouped-row shape (`source`, `type`, `_max`) the read + * used to take. Since v1.42 the module asks `listLiveMeasurementTypes` per + * source and then probes each type's newest row in one raw query; the two + * mocks below answer both from the same fixture rows. + */ +type GroupedRow = { + source: string; + type: string; + _max: { measuredAt: Date | null }; +}; +let measurementRows: GroupedRow[] = []; +const groupByMock = { + mockResolvedValue(rows: GroupedRow[]) { + measurementRows = rows; + }, +}; +const liveTypesMock = vi.hoisted(() => vi.fn()); +const queryRawMock = vi.hoisted(() => vi.fn()); const workoutGroupByMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/db", () => ({ prisma: { - measurement: { groupBy: groupByMock }, + $queryRaw: queryRawMock, workout: { groupBy: workoutGroupByMock }, }, })); +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: liveTypesMock, +})); import { getSourceFreshness, @@ -28,7 +49,32 @@ import { } from "../sync-verdict"; beforeEach(() => { - groupByMock.mockReset(); + measurementRows = []; + liveTypesMock.mockReset(); + liveTypesMock.mockImplementation( + async (_userId: string, options: { source: string }) => + measurementRows + .filter((row) => row.source === options.source) + .map((row) => row.type), + ); + queryRawMock.mockReset(); + queryRawMock.mockImplementation( + async (_strings: TemplateStringsArray, ...values: unknown[]) => { + const types = values.find(Array.isArray) as string[]; + const source = values.find( + (value) => + typeof value === "string" && + measurementRows.some((row) => row.source === value), + ); + return types.map((type) => ({ + type, + last_seen: + measurementRows.find( + (row) => row.source === source && row.type === type, + )?._max.measuredAt ?? null, + })); + }, + ); workoutGroupByMock.mockReset(); workoutGroupByMock.mockResolvedValue([]); }); @@ -65,11 +111,10 @@ describe("getSourceMetricFreshness", () => { expect(result.withings).toEqual([ { type: "WEIGHT", lastSeenAt: "2026-07-06T00:00:00.000Z" }, ]); - // Only the sync sources are queried. - expect(groupByMock.mock.calls[0][0].where.source.in).toEqual( - Object.values(INTEGRATION_MEASUREMENT_SOURCE), - ); - expect(groupByMock.mock.calls[0][0].where.deletedAt).toBeNull(); + // Only the sync sources are asked about, one live-type walk each. + expect( + liveTypesMock.mock.calls.map((call) => call[1].source).sort(), + ).toEqual([...Object.values(INTEGRATION_MEASUREMENT_SOURCE)].sort()); }); it("skips rows with no reading and unmapped sources", async () => { @@ -175,9 +220,30 @@ describe("getSourceFreshness — one source, for Apple Health", () => { expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([ { type: "RESPIRATORY_RATE", lastSeenAt: "2026-07-01T00:00:00.000Z" }, ]); - expect(groupByMock.mock.calls[0][0].where.source.in).toEqual([ - "APPLE_HEALTH", + expect(liveTypesMock).toHaveBeenCalledTimes(1); + expect(liveTypesMock.mock.calls[0][1]).toEqual({ source: "APPLE_HEALTH" }); + }); + + it("issues no per-type probe when the source has no live types", async () => { + expect(await getSourceFreshness("u1", "APPLE_HEALTH")).toEqual([]); + expect(queryRawMock).not.toHaveBeenCalled(); + }); + + it("probes live rows of the one source only", async () => { + groupByMock.mockResolvedValue([ + { + source: "APPLE_HEALTH", + type: "PULSE", + _max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") }, + }, ]); + await getSourceFreshness("u1", "APPLE_HEALTH"); + const sql = (queryRawMock.mock.calls[0][0] as TemplateStringsArray).join( + "?", + ); + expect(sql).toContain('"deleted_at" IS NULL'); + expect(sql).toContain('"source" = ?::"measurement_source"'); + expect(sql).toContain("LIMIT 1"); }); }); diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index daef60a49..1945b8131 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -7,13 +7,30 @@ * it to show which types are actually flowing. A scoped ingest credential * does not write it, the same rule as `lastSyncedAt`. * - * Contract stub: the signature is final; the body records nothing yet. + * Sample time (`max(measuredAt)`, what the freshness list showed before) and + * arrival time are different questions. A type the phone only hands over + * when someone taps "Sync all" can carry a reading from this morning and + * still never have arrived in the background; only the arrival time and the + * trigger that carried it can show that. */ import type { MeasurementType, Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { logCaught } from "@/lib/logging/signal"; +import { + WORKOUT_FRESHNESS_TYPE, + type MetricFreshnessEntry, +} from "./sync-verdict"; export type HealthKitSyncTrigger = "foreground" | "background" | "push" | "manual"; +const TRIGGERS: ReadonlySet = new Set([ + "foreground", + "background", + "push", + "manual", +]); + /** What one batch did for one type. */ export interface HealthKitTypeArrival { /** Entries of this type the batch accepted (inserted, updated or duplicate). */ @@ -22,6 +39,16 @@ export interface HealthKitTypeArrival { newSamples: number; } +/** + * One statement for the whole batch (`unnest` over at most a few dozen + * 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. + * + * 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. + */ export async function recordHealthKitTypeArrivals( tx: Prisma.TransactionClient, input: { @@ -31,6 +58,111 @@ export async function recordHealthKitTypeArrivals( at?: Date; }, ): Promise { - void tx; - void input; + const types: string[] = []; + const brought: boolean[] = []; + for (const [type, arrival] of input.counts) { + if (arrival.accepted <= 0) continue; + types.push(type); + brought.push(arrival.newSamples > 0); + } + if (types.length === 0) return; + // An ISO string cast in SQL, not a JS `Date`: a Date parameter is + // 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(); + 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", + ${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger}, + CASE WHEN u.n 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", + "last_trigger" = EXCLUDED."last_trigger", + "last_new_sample_at" = COALESCE( + EXCLUDED."last_new_sample_at", + "healthkit_type_syncs"."last_new_sample_at" + ) + `; + } catch (error) { + logCaught("healthkit.type_sync.record_failed", error, { + types: types.length, + }); + } +} + +/** One type's arrival facts, as the Apple Health status read serves them. */ +export interface HealthKitTypeArrivalRecord { + lastReceivedAt: string; + lastTrigger: HealthKitSyncTrigger | null; + lastNewSampleAt: string | null; +} + +/** + * Every recorded type for one account, keyed by `MeasurementType`. 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. + */ +export async function getHealthKitTypeArrivals( + userId: string, +): Promise> { + const rows = await prisma.healthKitTypeSync.findMany({ + where: { userId }, + select: { + type: true, + lastReceivedAt: true, + lastTrigger: true, + lastNewSampleAt: true, + }, + }); + const out = new Map(); + for (const row of rows) { + out.set(row.type, { + lastReceivedAt: row.lastReceivedAt.toISOString(), + lastTrigger: + row.lastTrigger && TRIGGERS.has(row.lastTrigger) + ? (row.lastTrigger as HealthKitSyncTrigger) + : null, + lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null, + }); + } + return out; +} + +/** A freshness entry with the arrival facts beside the newest sample. */ +export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { + /** When a live sync last carried this type; null when none has. */ + lastReceivedAt?: string | null; + /** The trigger of that sync; null when absent or not reported. */ + lastTrigger?: HealthKitSyncTrigger | null; + /** When a live sync last brought a new or updated sample of it. */ + lastNewSampleAt?: 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 + * 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 + * "not received yet" would be false. A null ledger (the read failed) leaves + * every entry as it was rather than claiming nothing arrived. + */ +export function withHealthKitArrivals( + entries: readonly MetricFreshnessEntry[], + arrivals: ReadonlyMap | null, +): HealthKitFreshnessEntry[] { + if (!arrivals) return [...entries]; + return entries.map((entry) => { + if (entry.type === WORKOUT_FRESHNESS_TYPE) return entry; + const arrival = arrivals.get(entry.type); + return { + ...entry, + lastReceivedAt: arrival?.lastReceivedAt ?? null, + lastTrigger: arrival?.lastTrigger ?? null, + lastNewSampleAt: arrival?.lastNewSampleAt ?? null, + }; + }); } diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts index b5f6e80c2..d45eb557d 100644 --- a/src/lib/integrations/metric-freshness.ts +++ b/src/lib/integrations/metric-freshness.ts @@ -10,7 +10,7 @@ * but idle". * * This computes the last-value timestamp per `(source, type)` straight from the - * `Measurement` table (no schema change, no migration) so a caller can surface + * `Measurement` table so a caller can surface * per-metric liveness honestly: a metric whose newest reading is frozen weeks in * the past is visibly distinct from one that is genuinely current. The * classification of "quiet" vs "current" is a pure function next to the verdict @@ -22,7 +22,11 @@ * pseudo-entry so those pipes are visible too. */ import { prisma } from "@/lib/db"; -import type { MeasurementSource } from "@/generated/prisma/client"; +import type { + MeasurementSource, + MeasurementType, +} from "@/generated/prisma/client"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import type { IntegrationKey } from "./status"; import { WORKOUT_FRESHNESS_TYPE, @@ -70,34 +74,68 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial< }; /** - * Newest measurement per `(source, type)` for the given sources. One grouped - * query; a `(source, type)` pair with no rows simply has no entry, which is how - * honest absence stays structural — a metric a provider never delivered is - * never invented. + * Newest measurement per `(source, type)` for the given sources. A `(source, + * type)` pair with no rows simply has no entry, which is how honest absence + * stays structural — a metric a provider never delivered is never invented. + * + * v1.42: this used to be one `groupBy … _max(measuredAt)` over every live row + * of the sources, which on a multi-year Apple Health history reads six figures + * of rows for a two-digit answer on every settings visit. It now asks which + * types are live per source (`listLiveMeasurementTypes`, a loose index scan) + * and then probes each type's newest row through the same partial covering + * index, one `LIMIT 1` per type. */ export async function getMeasurementFreshnessBySource( userId: string, sources: readonly MeasurementSource[], ): Promise> { const out = new Map(); - if (sources.length === 0) return out; - - const grouped = await prisma.measurement.groupBy({ - by: ["source", "type"], - where: { userId, deletedAt: null, source: { in: [...sources] } }, - _max: { measuredAt: true }, - }); - - for (const row of grouped) { - const lastSeen = row._max.measuredAt; - if (!lastSeen) continue; - const list = out.get(row.source) ?? []; - list.push({ type: row.type, lastSeenAt: lastSeen.toISOString() }); - out.set(row.source, list); + const perSource = await Promise.all( + [...new Set(sources)].map(async (source) => { + const types = await listLiveMeasurementTypes(userId, { source }); + return { source, samples: await newestPerType(userId, source, types) }; + }), + ); + for (const { source, samples } of perSource) { + if (samples.length > 0) out.set(source, samples); } return out; } +async function newestPerType( + userId: string, + source: MeasurementSource, + types: readonly MeasurementType[], +): Promise { + if (types.length === 0) return []; + // Parameter-bound throughout; the enum casts are literal SQL. + const rows = await prisma.$queryRaw< + Array<{ type: string; last_seen: Date | null }> + >` + SELECT live."type"::text AS "type", + ( + SELECT m."measured_at" + FROM "measurements" m + WHERE m."user_id" = ${userId} + AND m."type" = live."type" + AND m."deleted_at" IS NULL + AND m."source" = ${source}::"measurement_source" + ORDER BY m."measured_at" DESC + LIMIT 1 + ) AS "last_seen" + FROM unnest(${[...types]}::"measurement_type"[]) AS live("type") + `; + const samples: MetricFreshnessSample[] = []; + for (const row of rows) { + if (!row.last_seen) continue; + samples.push({ + type: row.type, + lastSeenAt: new Date(row.last_seen).toISOString(), + }); + } + return samples; +} + /** * Newest workout per source. `Workout` rows are hard-deleted, so there is no * tombstone predicate to apply. diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 320520843..ad7793b75 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -117,6 +117,38 @@ const metricFreshnessEntry = z "Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.", }); +// #1173 — the Apple Health read adds when each type last ARRIVED, under which +// trigger, and when a sync last brought something new for it. Mirrors +// `HealthKitFreshnessEntry` in `src/lib/integrations/healthkit-type-sync.ts`. +const appleHealthMetricFreshnessEntry = metricFreshnessEntry + .extend({ + lastReceivedAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null when no live sync has carried it yet: a type that so far came only from the export import, or from before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read failed.", + ), + lastTrigger: healthKitSyncTriggerEnum + .nullable() + .optional() + .describe( + "The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`.", + ), + lastNewSampleAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .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`.", + ), + }) + .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).", + }); + // Issue #778 — the two backfill-progress figures the server genuinely holds. // The iOS app drives the backfill; its queue, throttle state, and any ETA // live on the device and are deliberately absent here. @@ -167,10 +199,10 @@ const healthKitConfigResponse = z .optional() .describe("Present on the GET read; omitted from the PATCH echo."), metricFreshness: z - .array(metricFreshnessEntry) + .array(appleHealthMetricFreshnessEntry) .optional() .describe( - "Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.", + "Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the GET read; omitted from the PATCH echo.", ), syncProgress: appleHealthSyncProgress .nullable() diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index b063609c8..cbc3efa0d 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`. 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. 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/tests/integration/healthkit-type-sync.test.ts b/tests/integration/healthkit-type-sync.test.ts new file mode 100644 index 000000000..7074a5e5b --- /dev/null +++ b/tests/integration/healthkit-type-sync.test.ts @@ -0,0 +1,302 @@ +/** + * Per-type HealthKit arrival ledger (#1173), against real Postgres. + * + * The Apple Health card used to show only when each type's newest SAMPLE was + * taken. A type the phone hands over only on "Sync all" can carry a reading + * from this morning and still never arrive in the background, so the card + * needs when the server RECEIVED each type and under which trigger. These + * cases pin the batch route's write, the duplicate rule for + * `lastNewSampleAt`, the scoped-credential exclusion, and the status read + * that serves it, including the newest-sample probe that replaced the + * grouped read. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-healthkit-type-sync-32-bytes-minimum-123456"; + +const { hashToken } = await import("@/lib/auth/hmac"); + +const USER_ID = "user-healthkit-type-sync"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "healthkit-type-sync", + email: "healthkit-type-sync@example.test", + timezone: "UTC", + }, + }); +}); + +async function armSession(): Promise { + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function entry( + hkIdentifier: string, + value: number, + unit: string, + at: string, + externalId: string, +) { + return { hkIdentifier, value, unit, startDate: at, endDate: at, externalId }; +} + +async function postBatch( + body: Record, + headers: Record = {}, +): Promise { + const { POST } = await import("@/app/api/measurements/batch/route"); + return POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }), + ); +} + +const T1 = "2026-10-01T06:00:00.000Z"; +const T2 = "2026-10-01T07:00:00.000Z"; + +describe("per-type arrival ledger (#1173)", () => { + it("records each type a batch carried, with its trigger", async () => { + await armSession(); + const res = await postBatch({ + syncTrigger: "manual", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + entry( + "HKQuantityTypeIdentifierHeartRate", + 61, + "count/min", + T1, + "uuid-p1", + ), + entry( + "HKQuantityTypeIdentifierHeartRate", + 63, + "count/min", + T2, + "uuid-p2", + ), + ], + }); + expect(res.status).toBe(200); + + const rows = await getPrismaClient().healthKitTypeSync.findMany({ + where: { userId: USER_ID }, + orderBy: { type: "asc" }, + }); + expect(rows.map((row) => row.type).sort()).toEqual(["PULSE", "WEIGHT"]); + for (const row of rows) { + expect(row.lastTrigger).toBe("manual"); + expect(row.lastNewSampleAt?.toISOString()).toBe( + row.lastReceivedAt.toISOString(), + ); + // Stored as the instant it is, whatever zone the process runs in. + expect(Math.abs(row.lastReceivedAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + } + }); + + it("moves the arrival but keeps the last new value on a duplicates-only re-send", async () => { + await armSession(); + const weight = entry( + "HKQuantityTypeIdentifierBodyMass", + 70, + "kg", + T1, + "uuid-w1", + ); + await postBatch({ syncTrigger: "foreground", entries: [weight] }); + const first = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + await postBatch({ syncTrigger: "background", entries: [weight] }); + const second = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + + expect(second.lastTrigger).toBe("background"); + expect(second.lastReceivedAt.getTime()).toBeGreaterThan( + first.lastReceivedAt.getTime(), + ); + expect(second.lastNewSampleAt?.toISOString()).toBe( + first.lastNewSampleAt?.toISOString(), + ); + }); + + it("stores a missing trigger as null rather than keeping the previous one", async () => { + await armSession(); + await postBatch({ + syncTrigger: "push", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 70, "kg", T1, "uuid-w1"), + ], + }); + await postBatch({ + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 71, "kg", T2, "uuid-w2"), + ], + }); + const row = await getPrismaClient().healthKitTypeSync.findUniqueOrThrow({ + where: { userId_type: { userId: USER_ID, type: "WEIGHT" } }, + }); + expect(row.lastTrigger).toBeNull(); + }); + + it("records nothing for MANUAL rows or for a scoped credential", async () => { + await armSession(); + await postBatch({ + syncTrigger: "foreground", + entries: [ + { + ...entry( + "HKQuantityTypeIdentifierBodyMass", + 70, + "kg", + T1, + "manual-w1", + ), + source: "MANUAL", + }, + ], + }); + cookieJar.clear(); + + const raw = `hlk_typesync_${"0".repeat(48)}`; + await getPrismaClient().apiToken.create({ + data: { + userId: USER_ID, + name: "bridge", + tokenHash: hashToken(raw), + permissions: ["measurements:write"], + }, + }); + headerJar.set("authorization", `Bearer ${raw}`); + const res = await postBatch( + { + syncTrigger: "background", + entries: [ + entry("HKQuantityTypeIdentifierBodyMass", 72, "kg", T2, "uuid-b1"), + ], + }, + { authorization: `Bearer ${raw}` }, + ); + expect(res.status).toBe(200); + + expect( + await getPrismaClient().healthKitTypeSync.count({ + where: { userId: USER_ID }, + }), + ).toBe(0); + }); + + it("serves the arrival facts beside the newest live sample", async () => { + await armSession(); + await postBatch({ + syncTrigger: "manual", + entries: [ + entry( + "HKQuantityTypeIdentifierRespiratoryRate", + 14, + "count/min", + T1, + "uuid-r1", + ), + entry( + "HKQuantityTypeIdentifierRespiratoryRate", + 15, + "count/min", + T2, + "uuid-r2", + ), + ], + }); + // A type that came only from the export import: live rows, no arrival. + await getPrismaClient().measurement.create({ + data: { + userId: USER_ID, + type: "WEIGHT", + value: 70, + unit: "kg", + measuredAt: new Date(T1), + source: "APPLE_HEALTH", + externalId: "xml-w1", + }, + }); + // The newest respiratory row is a tombstone; the read must skip it. + await getPrismaClient().measurement.updateMany({ + where: { userId: USER_ID, externalId: "uuid-r2" }, + data: { deletedAt: new Date() }, + }); + + const { GET } = await import("@/app/api/integrations/healthkit/route"); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/integrations/healthkit"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metricFreshness: Array<{ + type: string; + lastSeenAt: string; + lastReceivedAt?: string | null; + lastTrigger?: string | null; + }>; + }; + }; + const byType = Object.fromEntries( + body.data.metricFreshness.map((row) => [row.type, row]), + ); + expect(byType.RESPIRATORY_RATE.lastSeenAt).toBe(T1); + expect(byType.RESPIRATORY_RATE.lastTrigger).toBe("manual"); + expect(byType.RESPIRATORY_RATE.lastReceivedAt).toEqual(expect.any(String)); + expect(byType.WEIGHT).toMatchObject({ + lastSeenAt: T1, + lastReceivedAt: null, + lastTrigger: null, + }); + }); +});