From 06935256b0e5fde065dbe5d0872c634171c8b0b5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 27 Jul 2026 13:33:51 +0200
Subject: [PATCH] feat(integrations): let the ledger hold every failing leg at
once
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.
The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.
An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.
The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
---
docs/api/openapi.yaml | 30 ++++-
messages/de.json | 16 ++-
messages/en.json | 16 ++-
messages/es.json | 16 ++-
messages/fr.json | 16 ++-
messages/it.json | 16 ++-
messages/pl.json | 16 ++-
.../migration.sql | 38 ++++++
prisma/schema.prisma | 42 +++++--
src/app/api/integrations/healthkit/route.ts | 19 ++-
src/app/api/measurements/batch/route.ts | 34 ++++--
.../settings/integration-status-pill.tsx | 8 +-
.../integrations/apple-health-card.tsx | 79 ++++++++++++-
src/lib/data-wipe/wipe-plan.ts | 2 +
src/lib/integrations/metric-freshness.ts | 6 +
src/lib/integrations/status.ts | 108 +++++++++++++++---
src/lib/openapi/routes/integrations.ts | 18 +++
src/lib/openapi/routes/measurements.ts | 11 +-
18 files changed, 433 insertions(+), 58 deletions(-)
create mode 100644 prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 8c734b58e..dac00eb9e 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -12100,10 +12100,11 @@ components:
items:
$ref: "#/components/schemas/AppleHealthBatchEntry"
syncTrigger:
- description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake)
- so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere
- else — it does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible:
- pre-#66 clients omit it."
+ description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake).
+ 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."
type: string
enum:
- foreground
@@ -32631,6 +32632,27 @@ components:
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"
description: When HealthKit last synced for this user; null when never (and always null on the PATCH echo).
+ lastSyncTrigger:
+ description: What the client declared triggered the most recent accepted batch. Null when the client sent no trigger —
+ an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo.
+ anyOf:
+ - type: string
+ enum:
+ - foreground
+ - background
+ - push
+ description: What triggered a HealthKit batch.
+ - type: "null"
+ lastBackgroundSyncAt:
+ description: When a batch last arrived without the app being opened (a `background` or `push` trigger). Null when that
+ has never happened. `lastSyncedAt` alone cannot separate a phone that delivers on its own from one that only
+ delivers while the app is open; this field is what does. Present on the GET read; omitted from the PATCH
+ echo.
+ 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"
syncHealth:
description: Present on the GET read; omitted from the PATCH echo.
$ref: "#/components/schemas/SyncHealth"
diff --git a/messages/de.json b/messages/de.json
index 6cacb7616..5c3cc6864 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5297,7 +5297,6 @@
"minutesAgo": "vor {count} min",
"hoursAgo": "vor {count} Std.",
"daysAgo": "vor {count} d",
- "ariaLabel": "Integrationsstatus",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen",
@@ -5841,7 +5840,20 @@
"backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.",
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
"importAction": "Einmaligen Import öffnen",
- "staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen."
+ "staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.",
+ "delivery": {
+ "title": "Übermittlung",
+ "lastTriggerLabel": "Letzte Synchronisierung kam",
+ "lastBackgroundLabel": "Letzte Hintergrund-Synchronisierung",
+ "backgroundNever": "Nie — bisher kam jede Synchronisierung bei geöffneter App an",
+ "noSyncYet": "Es ist noch keine Synchronisierung angekommen, daher gibt es hier nichts zu berichten.",
+ "trigger": {
+ "foreground": "bei geöffneter App",
+ "background": "im Hintergrund",
+ "push": "durch einen Push-Weckruf",
+ "unknown": "Auslöser von der App nicht gemeldet"
+ }
+ }
},
"garminInfo": {
"title": "Garmin",
diff --git a/messages/en.json b/messages/en.json
index f7e401020..f93b91fdf 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5297,7 +5297,6 @@
"minutesAgo": "{count} min ago",
"hoursAgo": "{count} h ago",
"daysAgo": "{count} d ago",
- "ariaLabel": "Integration status",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed",
@@ -5841,7 +5840,20 @@
"backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.",
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import",
- "staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions."
+ "staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
+ "delivery": {
+ "title": "Delivery",
+ "lastTriggerLabel": "Latest sync arrived",
+ "lastBackgroundLabel": "Last background sync",
+ "backgroundNever": "Never — every sync so far arrived with the app open",
+ "noSyncYet": "No sync has arrived yet, so there is nothing to report here.",
+ "trigger": {
+ "foreground": "with the app open",
+ "background": "in the background",
+ "push": "from a push wake",
+ "unknown": "trigger not reported by the app"
+ }
+ }
},
"garminInfo": {
"title": "Garmin",
diff --git a/messages/es.json b/messages/es.json
index a856d5850..a1248c1fe 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5297,7 +5297,6 @@
"minutesAgo": "hace {count} min",
"hoursAgo": "hace {count} h",
"daysAgo": "hace {count} d",
- "ariaLabel": "Estado de la integración",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida",
@@ -5841,7 +5840,20 @@
"backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.",
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual",
- "staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health."
+ "staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
+ "delivery": {
+ "title": "Entrega",
+ "lastTriggerLabel": "La última sincronización llegó",
+ "lastBackgroundLabel": "Última sincronización en segundo plano",
+ "backgroundNever": "Nunca — hasta ahora todas las sincronizaciones llegaron con la app abierta",
+ "noSyncYet": "Aún no ha llegado ninguna sincronización, así que no hay nada que informar aquí.",
+ "trigger": {
+ "foreground": "con la app abierta",
+ "background": "en segundo plano",
+ "push": "desde un aviso push",
+ "unknown": "la app no informó del desencadenante"
+ }
+ }
},
"garminInfo": {
"title": "Garmin",
diff --git a/messages/fr.json b/messages/fr.json
index 052b02a26..79d87c87a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5297,7 +5297,6 @@
"minutesAgo": "il y a {count} min",
"hoursAgo": "il y a {count} h",
"daysAgo": "il y a {count} j",
- "ariaLabel": "Statut de l’intégration",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion",
@@ -5841,7 +5840,20 @@
"backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.",
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel",
- "staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health."
+ "staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
+ "delivery": {
+ "title": "Livraison",
+ "lastTriggerLabel": "La dernière synchronisation est arrivée",
+ "lastBackgroundLabel": "Dernière synchronisation en arrière-plan",
+ "backgroundNever": "Jamais — jusqu'ici chaque synchronisation est arrivée avec l'app ouverte",
+ "noSyncYet": "Aucune synchronisation n'est encore arrivée, il n'y a donc rien à signaler ici.",
+ "trigger": {
+ "foreground": "avec l'app ouverte",
+ "background": "en arrière-plan",
+ "push": "depuis un réveil push",
+ "unknown": "déclencheur non signalé par l'app"
+ }
+ }
},
"garminInfo": {
"title": "Garmin",
diff --git a/messages/it.json b/messages/it.json
index deda44729..a192e831f 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5297,7 +5297,6 @@
"minutesAgo": "{count} min fa",
"hoursAgo": "{count} h fa",
"daysAgo": "{count} g fa",
- "ariaLabel": "Stato dell’integrazione",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita",
@@ -5841,7 +5840,20 @@
"backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.",
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum",
- "staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health."
+ "staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
+ "delivery": {
+ "title": "Consegna",
+ "lastTriggerLabel": "L'ultima sincronizzazione è arrivata",
+ "lastBackgroundLabel": "Ultima sincronizzazione in background",
+ "backgroundNever": "Mai — finora ogni sincronizzazione è arrivata con l'app aperta",
+ "noSyncYet": "Non è ancora arrivata alcuna sincronizzazione, quindi qui non c'è nulla da riferire.",
+ "trigger": {
+ "foreground": "con l'app aperta",
+ "background": "in background",
+ "push": "da una sveglia push",
+ "unknown": "trigger non segnalato dall'app"
+ }
+ }
},
"garminInfo": {
"title": "Garmin",
diff --git a/messages/pl.json b/messages/pl.json
index cb49beb28..c7281731c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5297,7 +5297,6 @@
"minutesAgo": "{count} min temu",
"hoursAgo": "{count} godz. temu",
"daysAgo": "{count} dni temu",
- "ariaLabel": "Status integracji",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane",
@@ -5841,7 +5840,20 @@
"backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.",
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import",
- "staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health."
+ "staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
+ "delivery": {
+ "title": "Dostarczanie",
+ "lastTriggerLabel": "Ostatnia synchronizacja dotarła",
+ "lastBackgroundLabel": "Ostatnia synchronizacja w tle",
+ "backgroundNever": "Nigdy — jak dotąd każda synchronizacja dotarła przy otwartej aplikacji",
+ "noSyncYet": "Żadna synchronizacja jeszcze nie dotarła, więc nie ma tu nic do zgłoszenia.",
+ "trigger": {
+ "foreground": "przy otwartej aplikacji",
+ "background": "w tle",
+ "push": "z wybudzenia push",
+ "unknown": "aplikacja nie zgłosiła wyzwalacza"
+ }
+ }
},
"garminInfo": {
"title": "Garmin",
diff --git a/prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql b/prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql
new file mode 100644
index 000000000..c49c4a46c
--- /dev/null
+++ b/prisma/migrations/0281_integration_failing_legs_and_healthkit_trigger/migration.sql
@@ -0,0 +1,38 @@
+-- The integration ledger stops erasing itself, and the HealthKit trigger gets
+-- somewhere to live.
+--
+-- 1. `failing_leg` → `failing_legs`
+--
+-- `failing_leg` was a single last-writer-wins slot. Withings runs four legs on
+-- four crons and WHOOP four resources on four more, so two legs failing in
+-- overlapping windows overwrote each other: the row remembered whichever failed
+-- last, and a success from that leg then cleared an error the other one was
+-- still causing. The slot becomes a set. A failure adds its leg, a success
+-- removes its own, and the row's error state, per-kind buckets and streak
+-- anchor clear only when the set is empty.
+--
+-- The array is ordered oldest-failure-first, so the last element is the leg that
+-- owns whatever message `last_error` holds.
+--
+-- 2. `users`: the HealthKit sync trigger, persisted
+--
+-- The batch route has accepted an optional `syncTrigger` since v1.32.8 and put
+-- it on the wide event, where no human reads it. Persisting it — plus the
+-- instant of the most recent background-triggered batch — is what lets the
+-- Apple Health card answer whether the phone is delivering on its own or only
+-- when the app is open. Both nullable and additive.
+
+ALTER TABLE "integration_statuses" ADD COLUMN "failing_legs" JSONB;
+
+-- Carry every attributed leg forward as a one-element set. Rows whose
+-- `failing_leg` is NULL become NULL too, not an empty array: NULL and `[]` both
+-- read as "failing, but unattributed", and leaving them NULL keeps the backfill
+-- to the rows that actually carry information.
+UPDATE "integration_statuses"
+SET "failing_legs" = jsonb_build_array("failing_leg")
+WHERE "failing_leg" IS NOT NULL;
+
+ALTER TABLE "integration_statuses" DROP COLUMN "failing_leg";
+
+ALTER TABLE "users" ADD COLUMN "healthkit_last_sync_trigger" TEXT;
+ALTER TABLE "users" ADD COLUMN "healthkit_last_background_sync_at" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 367884b30..60e1fecf7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -319,9 +319,9 @@ model User {
// must never be the OpenAI key. Separate columns make that structural
// instead of a comment. The base URL passes the same SSRF floor as the
// Local provider (`isPublicUrl` unless the operator allowlisted the host).
- aiCompatBaseUrl String? @map("ai_compat_base_url")
- aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted")
- aiCompatModel String? @map("ai_compat_model")
+ aiCompatBaseUrl String? @map("ai_compat_base_url")
+ aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted")
+ aiCompatModel String? @map("ai_compat_model")
// v1.4.16 phase B5b — ordered fallback chain for multi-provider AI
// redundancy. Each entry is `{ providerType, priority, enabled }`,
@@ -415,8 +415,20 @@ model User {
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
- healthKitConfigJson Json? @map("healthkit_config_json")
- healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
+ healthKitConfigJson Json? @map("healthkit_config_json")
+ healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
+ // What triggered the most recent accepted HealthKit batch — "foreground",
+ // "background" or "push", exactly as the client declared it on the write.
+ // Null when the client sent no trigger (an older build, or a batch that
+ // predates the field).
+ //
+ // `healthKitLastBackgroundSyncAt` dates the most recent batch that arrived
+ // WITHOUT the app being opened. It is the one instrument that tells "the
+ // phone is delivering on its own" apart from "every arrival happened while
+ // the app was on screen", which is otherwise indistinguishable from the
+ // outside — `healthKitLastSyncedAt` alone looks identical either way.
+ healthKitLastSyncTrigger String? @map("healthkit_last_sync_trigger")
+ healthKitLastBackgroundSyncAt DateTime? @map("healthkit_last_background_sync_at")
// Display name (optional override of username for greetings).
displayName String? @map("display_name")
@@ -4175,11 +4187,21 @@ model IntegrationStatus {
// blip apart from a dead pipe. Cleared by a success that is entitled to
// clear it, by a reconnect, a disconnect, and a resume.
failingSinceAt DateTime? @map("failing_since_at")
- // Which sync leg owns the error currently on the row. `null` = the provider
- // has a single leg, or the failure predates leg attribution. A success from
- // a DIFFERENT leg may not clear an error this one did not cause — see
- // `recordSyncSuccess`.
- failingLeg String? @map("failing_leg")
+ // Every sync leg currently known to be failing, oldest failure first, as a
+ // JSON string array. A single slot could only ever remember one leg, so two
+ // legs of the same provider failing in overlapping windows erased each other
+ // and the row forgot one of them. A failure adds its leg; a success removes
+ // its own; the error state, the buckets and `failingSinceAt` clear only when
+ // the array is empty — see `recordSyncSuccess`.
+ //
+ // The LAST element is the leg that failed most recently and therefore owns
+ // whatever message `lastError` holds. When that leg recovers while others are
+ // still failing, `lastError` is cleared rather than left describing a leg that
+ // is now healthy.
+ //
+ // An empty array means "failing, but unattributed" (a single-leg provider, or
+ // a failure recorded without a leg), under which any success clears the row.
+ failingLegs Json? @map("failing_legs")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts
index b2b354faa..8509b12ba 100644
--- a/src/app/api/integrations/healthkit/route.ts
+++ b/src/app/api/integrations/healthkit/route.ts
@@ -130,12 +130,27 @@ export const GET = apiHandler(async () => {
const row = await prisma.user.findUnique({
where: { id: user.id },
- select: { healthKitConfigJson: true, healthKitLastSyncedAt: true },
+ select: {
+ healthKitConfigJson: true,
+ healthKitLastSyncedAt: true,
+ healthKitLastSyncTrigger: true,
+ healthKitLastBackgroundSyncAt: true,
+ },
});
const stored = parseStored(row?.healthKitConfigJson ?? null);
const entries = mergeWithDefaults(stored);
const lastSyncedAt = row?.healthKitLastSyncedAt?.toISOString() ?? null;
+ // What triggered the most recent accepted batch, and when data last arrived
+ // without the app being opened. `lastSyncedAt` alone cannot separate those
+ // two cases, which is the whole diagnostic: a card that only ever says "last
+ // sync 10 minutes ago" reads the same whether the phone is delivering by
+ // itself or whether every arrival was the user opening the app. Null in
+ // either field is honest absence — an older client that declares no trigger,
+ // or a pipe that has never delivered in the background.
+ const lastSyncTrigger = row?.healthKitLastSyncTrigger ?? null;
+ const lastBackgroundSyncAt =
+ row?.healthKitLastBackgroundSyncAt?.toISOString() ?? null;
// Apple Health is push-based: the iOS app delivers and nothing here polls, so
// liveness is a data question, not an attempt question. Without a threshold a
@@ -157,6 +172,8 @@ export const GET = apiHandler(async () => {
return apiSuccess({
entries,
lastSyncedAt,
+ lastSyncTrigger,
+ lastBackgroundSyncAt,
syncHealth,
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
});
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index 77bd7252f..68cee2ff2 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -169,12 +169,14 @@ const batchEntrySchema = z.object({
});
// v1.32.8 (iOS #66) — optional per-REQUEST diagnostic tag naming what woke the
-// client for this sync. Purely observational: it flows into the
-// `measurement.batch.ingest` wide event and NOTHING else — never persisted,
-// never part of the dedup key, never an input to attribution or how a sample is
-// stored. Only the client knows its own wake reason, so it is client-asserted
-// by necessity; being diagnostic-only, that carries no trust surface. Optional
-// so every pre-#66 caller stays byte-for-byte unchanged.
+// client for this sync. Purely diagnostic: it flows into the
+// `measurement.batch.ingest` wide event and onto the account's HealthKit
+// diagnostic columns, which the Apple Health card reads. It is never part of
+// the dedup key and never an input to attribution or how a sample is stored —
+// sending it or omitting it produces byte-identical row outcomes. Only the
+// client knows its own wake reason, so it is client-asserted by necessity;
+// being diagnostic-only, that carries no trust surface. Optional so every
+// pre-#66 caller stays byte-for-byte unchanged.
const syncTriggerEnum = z.enum(["foreground", "background", "push"]);
const batchPayloadSchema = z.object({
@@ -644,10 +646,28 @@ async function postBatch(request: NextRequest): Promise {
// This batch route is the native HealthKit ingestion boundary. Stamp only
// after every attempted write has reached a durable, non-failed verdict;
// MANUAL-only and skipped-only batches are not HealthKit syncs.
+ //
+ // The trigger the client declared is stamped alongside, and a background- or
+ // push-triggered batch additionally dates `healthKitLastBackgroundSyncAt`.
+ // Without that second timestamp there is no way to tell a phone that delivers
+ // on its own from one that only ever delivers while the app is open — both
+ // look like a moving `healthKitLastSyncedAt`. A batch that declares no
+ // trigger writes null over the previous value rather than leaving a stale one
+ // standing: the field describes THIS sync, and an older build's silence is
+ // not evidence about it.
if (healthKitSyncSucceeded) {
+ const syncedAt = new Date();
+ const deliveredWithoutTheApp =
+ syncTrigger === "background" || syncTrigger === "push";
await prisma.user.update({
where: { id: user.id },
- data: { healthKitLastSyncedAt: new Date() },
+ data: {
+ healthKitLastSyncedAt: syncedAt,
+ healthKitLastSyncTrigger: syncTrigger ?? null,
+ ...(deliveredWithoutTheApp
+ ? { healthKitLastBackgroundSyncAt: syncedAt }
+ : {}),
+ },
});
}
diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx
index 0850617cc..15f15182f 100644
--- a/src/components/settings/integration-status-pill.tsx
+++ b/src/components/settings/integration-status-pill.tsx
@@ -201,7 +201,13 @@ export function IntegrationStatusPill({
? "destructive"
: "outline"
}
- aria-label={t("settings.integrationPill.ariaLabel")}
+ // No `aria-label` here. One used to sit on the badge and, being a
+ // generic string, it REPLACED the accessible name with itself — a screen
+ // reader announced the same words for "connected · 4 minutes ago" as for
+ // "error", which is the one distinction the pill exists to make. The
+ // visible label is the accessible name; the relative time stays
+ // `aria-hidden` because it is decoration on top of a state that already
+ // reads.
className={cn(
"max-w-full whitespace-nowrap",
state === "connected" && connectedChipClass,
diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx
index 135d45607..5a8e6ccab 100644
--- a/src/components/settings/integrations/apple-health-card.tsx
+++ b/src/components/settings/integrations/apple-health-card.tsx
@@ -18,7 +18,10 @@ import { HeartPulse, Upload } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
-import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
+import {
+ formatRelative,
+ IntegrationStatusPill,
+} from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
@@ -31,12 +34,84 @@ import { queryKeys } from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
+type SyncTrigger = "foreground" | "background" | "push";
+
interface HealthKitStatus {
lastSyncedAt: string | null;
+ /** What the client said woke it for the most recent accepted batch. */
+ lastSyncTrigger?: SyncTrigger | null;
+ /** When data last arrived without the app being opened. */
+ lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
}
+const TRIGGER_KEYS: Record = {
+ foreground: "settings.appleHealth.delivery.trigger.foreground",
+ background: "settings.appleHealth.delivery.trigger.background",
+ push: "settings.appleHealth.delivery.trigger.push",
+};
+
+/**
+ * Whether the phone is delivering on its own, or only while someone is looking
+ * at it. `lastSyncedAt` cannot tell those apart — a card that reports nothing
+ * but "last sync 10 minutes ago" reads identically in both cases, which is why
+ * "is background delivery working?" was unanswerable from either side. The
+ * trigger of the most recent batch and the date of the last background arrival
+ * are the two facts that settle it, and each is shown only when it exists: a
+ * client that reports no trigger says so rather than being guessed at.
+ */
+function DeliveryDiagnostic({
+ status,
+ t,
+}: {
+ status: HealthKitStatus;
+ t: (key: string, params?: Record) => string;
+}) {
+ const trigger = status.lastSyncTrigger ?? null;
+ const triggerLabel = t(
+ trigger
+ ? TRIGGER_KEYS[trigger]
+ : "settings.appleHealth.delivery.trigger.unknown",
+ );
+ const backgroundAt = status.lastBackgroundSyncAt;
+
+ return (
+
+
+ {t("settings.appleHealth.delivery.title")}
+
+ {status.lastSyncedAt ? (
+
+
+
-
+ {t("settings.appleHealth.delivery.lastTriggerLabel")}
+
+ - {triggerLabel}
+
+
+
-
+ {t("settings.appleHealth.delivery.lastBackgroundLabel")}
+
+ -
+ {backgroundAt
+ ? formatRelative(
+ Math.max(0, Date.now() - new Date(backgroundAt).getTime()),
+ t,
+ )
+ : t("settings.appleHealth.delivery.backgroundNever")}
+
+
+
+ ) : (
+
+ {t("settings.appleHealth.delivery.noSyncYet")}
+
+ )}
+
+ );
+}
+
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const {
@@ -96,6 +171,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
)}
+ {showPill && }
+
= {
strava: "STRAVA",
polar: "POLAR",
+ // These three write workouts too, and were missing from this table, so their
+ // cards could report every measurement type fresh while the workout leg had
+ // been silent for weeks — the freshness disclosure had no row to go quiet.
+ whoop: "WHOOP",
+ fitbit: "FITBIT",
+ "google-health": "GOOGLE_HEALTH",
};
/**
diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts
index 8edc70e9f..8ae5da996 100644
--- a/src/lib/integrations/status.ts
+++ b/src/lib/integrations/status.ts
@@ -229,6 +229,32 @@ const ERROR_STATES: ReadonlySet = new Set([
"parked",
]);
+/**
+ * Read the `failingLegs` JSON column into an ordered list of leg names.
+ *
+ * Anything that is not an array of strings — a legacy `null`, a hand-edited
+ * row, a shape from a future schema — reads as the empty set, which is the
+ * pre-existing "unattributed failure" semantics under which any success clears
+ * the row. Non-string members are dropped rather than coerced: a leg name is an
+ * identifier the caller passes, and inventing one from a number would let a
+ * success match a leg nobody ever recorded.
+ */
+function readFailingLegs(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value.filter((entry): entry is string => typeof entry === "string");
+}
+
+/**
+ * Append `leg` as the most recent failure, moving it to the end if it is
+ * already in the set. Ordering is load-bearing: the last element is the leg
+ * that wrote the message `lastError` currently holds, which is what lets a
+ * partial recovery tell "this message still describes a failing leg" from
+ * "this message belongs to a leg that has since recovered".
+ */
+function withLegRecorded(current: readonly string[], leg: string): string[] {
+ return [...current.filter((entry) => entry !== leg), leg];
+}
+
export interface IntegrationStatusSnapshot {
integration: IntegrationKey;
state: IntegrationState;
@@ -373,15 +399,26 @@ export interface RecordSyncSuccessOptions {
* the state that the sleep leg had recorded at :15. The failing leg's strike
* ladder was reset several times an hour, so it could never reach the alert
* threshold or the park window, and the card painted a green "connected · 12
- * minutes ago" over a data class that had stopped arriving. When the row holds
- * an error owned by a DIFFERENT named leg, this success records its attempt and
- * leaves the error standing.
+ * minutes ago" over a data class that had stopped arriving.
+ *
+ * This success removes its OWN leg from the failing set and clears the row only
+ * once that set is empty. The set matters because the previous single slot
+ * could remember one leg at a time: with sleep and activity both failing, the
+ * second failure overwrote the first, and the second leg recovering then wiped
+ * an error the first was still causing. The ledger forgot a live failure and
+ * went green over it — the exact case this set exists to hold.
*
* The partial path deliberately does NOT advance `lastSuccessAt`. That field is
* what the freshness arms and the card's "connected · X ago" read, and moving
* it forward while another leg is dead is exactly the sentence this change
* exists to stop the ledger from saying. `lastAttemptAt` does advance, so the
* row still reads as "being tried" rather than ageing into `stalled`.
+ *
+ * `lastError` is cleared on the partial path when the recovering leg is the one
+ * that wrote it. The message then describes a leg that is now healthy, and a
+ * row that keeps showing it is describing the wrong failure; the next attempt
+ * by a still-failing leg writes its own. No message is honest here — a guessed
+ * one would not be.
*/
export async function recordSyncSuccess(
userId: string,
@@ -394,18 +431,28 @@ export async function recordSyncSuccess(
if (leg !== null) {
const existing = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
- select: { state: true, failingLeg: true },
+ select: { state: true, failingLegs: true },
});
+ const failingLegs = readFailingLegs(existing?.failingLegs);
+ const remaining = failingLegs.filter((entry) => entry !== leg);
const heldByAnotherLeg =
existing != null &&
- existing.failingLeg != null &&
- existing.failingLeg !== leg &&
+ remaining.length > 0 &&
ERROR_STATES.has(existing.state);
if (heldByAnotherLeg) {
+ // The recovering leg owned `lastError` iff it was the most recent
+ // failure, i.e. the last element. Dropping it leaves the row without a
+ // message for any leg that is still failing, which is the honest state
+ // until one of them fails again and writes its own.
+ const ownedLastError = failingLegs[failingLegs.length - 1] === leg;
await prisma.integrationStatus.update({
where: { userId_integration: { userId, integration } },
- data: { lastAttemptAt: now },
+ data: {
+ lastAttemptAt: now,
+ failingLegs: remaining,
+ ...(ownedLastError ? { lastError: null } : {}),
+ },
});
// Meta only — the enclosing wide event owns its own action name (the
// cron task), and overwriting it here would rename the job.
@@ -414,7 +461,7 @@ export async function recordSyncSuccess(
integration_partial_success: {
integration,
succeeded_leg: leg,
- failing_leg: existing.failingLeg,
+ failing_legs: remaining,
},
},
});
@@ -435,6 +482,7 @@ export async function recordSyncSuccess(
lastSuccessAt: now,
lastAttemptAt: now,
consecutiveFailuresByKind: zeroBuckets(),
+ failingLegs: [],
},
update: {
state: "connected",
@@ -444,7 +492,7 @@ export async function recordSyncSuccess(
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
failingSinceAt: null,
- failingLeg: null,
+ failingLegs: [],
alertedAt: null,
},
});
@@ -518,10 +566,20 @@ export async function recordSyncFailure(
consecutiveFailuresByKind: true,
persistentFailureStartedAt: true,
failingSinceAt: true,
+ failingLegs: true,
alertedAt: true,
},
});
+ // The failing set, with this leg appended as the most recent failure. An
+ // unattributed failure (no leg — a single-leg provider, or a recorder that
+ // does not know which leg it caught) adds nothing and removes nothing: it
+ // carries no attribution to record, and erasing what other legs already
+ // reported would let one of their successes clear an error it did not fix.
+ const existingLegs = readFailingLegs(existing?.failingLegs);
+ const failingLegs =
+ leg !== null ? withLegRecorded(existingLegs, leg) : existingLegs;
+
// Resolve the starting bucket envelope:
// - existing JSON value if present
// - zero envelope for a row that has never written a bucket payload
@@ -627,7 +685,7 @@ export async function recordSyncFailure(
consecutiveFailuresByKind: buckets,
persistentFailureStartedAt: isPersistent ? now : null,
failingSinceAt,
- failingLeg: leg,
+ failingLegs,
},
update: {
state: newState,
@@ -636,7 +694,7 @@ export async function recordSyncFailure(
consecutiveFailuresByKind: buckets,
persistentFailureStartedAt,
failingSinceAt,
- failingLeg: leg,
+ failingLegs,
},
});
@@ -663,6 +721,10 @@ export async function recordSyncFailure(
// "we concluded it was after a day of retrying".
escalatedFrom: escalatedFromTransient ? kind : null,
failingLeg: leg,
+ // Every leg the row currently holds an error for, not just this one —
+ // the audit trail is where an operator reconstructs an overlapping
+ // outage, and one leg per row was never enough to do it.
+ failingLegs,
failingSinceAt: failingSinceAt.toISOString(),
streakAgeMs,
errorCode: errorCode ?? null,
@@ -776,7 +838,7 @@ export async function resumeIntegrationFromPark(
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
failingSinceAt: null,
- failingLeg: null,
+ failingLegs: [],
alertedAt: null,
},
});
@@ -833,11 +895,23 @@ export async function parkIntegrationAtReauth(opts: {
// audit log when the state or error changes.
const existing = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
- select: { state: true, lastError: true, failingSinceAt: true },
+ select: {
+ state: true,
+ lastError: true,
+ failingSinceAt: true,
+ failingLegs: true,
+ },
});
const isFreshPark =
existing?.state !== "error_reauth" || existing.lastError !== encryptedError;
+ // Same set semantics as `recordSyncFailure`: a scope-skip park adds its leg
+ // to whatever the row already holds rather than replacing it, so a sibling
+ // leg's independent failure survives the park.
+ const existingLegs = readFailingLegs(existing?.failingLegs);
+ const failingLegs =
+ leg !== null ? withLegRecorded(existingLegs, leg) : existingLegs;
+
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
@@ -851,7 +925,7 @@ export async function parkIntegrationAtReauth(opts: {
// 3-strike runway before paging.
consecutiveFailuresByKind: zeroBuckets(),
failingSinceAt: now,
- failingLeg: leg,
+ failingLegs,
},
update: {
state: "error_reauth",
@@ -861,7 +935,7 @@ export async function parkIntegrationAtReauth(opts: {
// bucket values are preserved exactly. This is the whole point
// of the helper.
failingSinceAt: existing?.failingSinceAt ?? now,
- failingLeg: leg,
+ failingLegs,
},
});
@@ -948,7 +1022,7 @@ export async function markDisconnected(
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
failingSinceAt: null,
- failingLeg: null,
+ failingLegs: [],
alertedAt: null,
},
});
@@ -977,7 +1051,7 @@ export async function markReconnected(
consecutiveFailuresByKind: zeroBuckets(),
persistentFailureStartedAt: null,
failingSinceAt: null,
- failingLeg: null,
+ failingLegs: [],
alertedAt: null,
},
});
diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts
index df79d5520..2453dbc21 100644
--- a/src/lib/openapi/routes/integrations.ts
+++ b/src/lib/openapi/routes/integrations.ts
@@ -15,6 +15,11 @@ const healthKitDirectionEnum = z
.enum(["bidirectional", "readOnly", "writeOnly", "disabled"])
.describe("Per-metric sync direction.");
+// Mirror the batch route's `syncTriggerEnum` — what woke the client for a sync.
+const healthKitSyncTriggerEnum = z
+ .enum(["foreground", "background", "push"])
+ .describe("What triggered a HealthKit batch.");
+
// Resolved entry (defaults merged): `kind` + `enabled` are always present.
const healthKitEntry = z
.object({
@@ -89,6 +94,19 @@ const healthKitConfigResponse = z
.describe(
"When HealthKit last synced for this user; null when never (and always null on the PATCH echo).",
),
+ lastSyncTrigger: healthKitSyncTriggerEnum
+ .nullable()
+ .optional()
+ .describe(
+ "What the client declared triggered the most recent accepted batch. Null when the client sent no trigger — an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo.",
+ ),
+ lastBackgroundSyncAt: z.iso
+ .datetime({ offset: true })
+ .nullable()
+ .optional()
+ .describe(
+ "When a batch last arrived without the app being opened (a `background` or `push` trigger). Null when that has never happened. `lastSyncedAt` alone cannot separate a phone that delivers on its own from one that only delivers while the app is open; this field is what does. Present on the GET read; omitted from the PATCH echo.",
+ ),
syncHealth: syncHealth
.optional()
.describe("Present on the GET read; omitted from the PATCH echo."),
diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts
index 4c4660d36..d9c62a33a 100644
--- a/src/lib/openapi/routes/measurements.ts
+++ b/src/lib/openapi/routes/measurements.ts
@@ -112,15 +112,16 @@ const batchPayloadSchema = z
.object({
entries: z.array(batchEntrySchema).min(1).max(500),
// v1.32.8 (iOS #66) — optional per-request diagnostic tag naming what woke
- // the client for this sync. Observability only: it is recorded on the
- // ingest wide event and NEVER persisted, deduped on, or used in
- // attribution — sending it or omitting it produces byte-identical row
- // outcomes. Omit on any client that does not track its wake reason.
+ // the client for this sync. Diagnostic only: it is recorded on the ingest
+ // wide event and on the account's HealthKit diagnostic fields, which
+ // `GET /api/integrations/healthkit` publishes. It is never deduped on or
+ // used in attribution — sending it or omitting it produces byte-identical
+ // row outcomes. Omit on any client that does not track its wake reason.
syncTrigger: z
.enum(["foreground", "background", "push"])
.optional()
.describe(
- "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake) so an operator can see, per batch, which trigger produced it. Recorded on the ingest wide event and nowhere else — 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, or a push wake). 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.",
),
})
.meta({