feat(apple-health): show when each type last arrived and how

The Apple Health card listed each type's newest sample. A type the phone
only hands over on "Sync all" can carry a reading from this morning and
never have arrived in the background, so the sample time could not answer
whether a type flows on its own.

The batch route's per-type ledger now records, in one upsert per batch,
when each Apple Health type last arrived, the trigger that carried it
(including the new `manual`), and when a batch last brought a new or
updated sample of it. Duplicate-only re-sends move the arrival but keep the
last new value; scoped credentials and MANUAL rows write nothing, the same
rule as the sync checkpoint. A ledger failure leaves a warn line and never
fails the ingest.

`GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` over each `metricFreshness` entry (null for a type no live
sync has carried, absent on the workouts entry and when the ledger read
failed), and the card shows them as a second line per type.

The per-type newest-sample read walks live types through
`listLiveMeasurementTypes` and probes each type's newest row instead of
grouping every live row of the source.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 09:22:49 +02:00
1 parent c8470cc1e6
commit ba99551faa
15 files changed
+976 -114

No files matched your search

+34 -2
View File
@@ -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()
+1 -1
View File
@@ -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({