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({